Skip to content

Commit bc92401

Browse files
authored
Merge branch 'main' into helm-collaboration-template
2 parents 5ae9993 + 5268699 commit bc92401

File tree

65 files changed

+2846
-981
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

65 files changed

+2846
-981
lines changed

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ and this project adheres to
1313
- 🚸(backend) make document search on title accent-insensitive #874
1414
- 🚩 add homepage feature flag #861
1515

16+
## Changed
17+
18+
⚡️(frontend) reduce unblocking time for config #867
19+
1620
## Fixed
1721

1822
- 🐛(helm) charts generate invalid YAML for collaboration API / WS #890
@@ -145,6 +149,10 @@ and this project adheres to
145149
- 🐛(email) invitation emails in receivers language
146150

147151

152+
## Fixed
153+
154+
- 🐛(backend) race condition create doc #633
155+
148156
## [2.2.0] - 2025-02-10
149157

150158
## Added

README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,6 @@ Welcome to Docs! The open source document editor where your notes can become kno
2424

2525
## Why use Docs ❓
2626

27-
⚠️ **Note that Docs provides docs/pdf exporters by loading [two BlockNote packages](https://github.com/suitenumerique/docs/blob/main/src/frontend/apps/impress/package.json#L22C7-L23C53), which we use under the AGPL-3.0 licence. Until we comply with the terms of this license, we recommend that you don't run Docs as a commercial product, unless you are willing to sponsor [BlockNote](https://github.com/TypeCellOS/BlockNote).**
28-
2927
Docs is a collaborative text editor designed to address common challenges in knowledge building and sharing.
3028

3129
### Write
@@ -39,11 +37,13 @@ Docs is a collaborative text editor designed to address common challenges in kno
3937
* 🤝 Collaborate with your team in real time
4038
* 🔒 Granular access control to ensure your information is secure and only shared with the right people
4139
* 📑 Professional document exports in multiple formats (.odt, .doc, .pdf) with customizable templates
42-
* 📚 Built-in wiki functionality to turn your team's collaborative work into organized knowledge `ETA 02/2025`
40+
* 📚 Built-in wiki functionality to turn your team's collaborative work into organized knowledge `ETA 05/2025`
4341

4442
### Self-host
4543
* 🚀 Easy to install, scalable and secure alternative to Notion, Outline or Confluence
4644

45+
⚠️ For the PDF and Docx export Docs relies on XL packages from BlockNote licenced in AGPL-3.0. Please make sure you fulfill your obligations regarding BlockNote licensing (see https://github.com/TypeCellOS/BlockNote/blob/main/packages/xl-pdf-exporter/LICENSE and https://www.blocknotejs.org/about#partner-with-us).
46+
4747
## Getting started 🔧
4848

4949
### Test it
@@ -118,6 +118,7 @@ $ make run-backend
118118
```
119119

120120
**Adding content**
121+
121122
You can create a basic demo site by running:
122123

123124
```shellscript

src/backend/core/api/viewsets.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@
1111
from django.contrib.postgres.search import TrigramSimilarity
1212
from django.core.exceptions import ValidationError
1313
from django.core.files.storage import default_storage
14+
from django.db import connection, transaction
1415
from django.db import models as db
15-
from django.db import transaction
1616
from django.db.models.expressions import RawSQL
1717
from django.db.models.functions import Left, Length
1818
from django.http import Http404, StreamingHttpResponse
@@ -607,6 +607,14 @@ def retrieve(self, request, *args, **kwargs):
607607
@transaction.atomic
608608
def perform_create(self, serializer):
609609
"""Set the current user as creator and owner of the newly created object."""
610+
611+
# locks the table to ensure safe concurrent access
612+
with connection.cursor() as cursor:
613+
cursor.execute(
614+
f'LOCK TABLE "{models.Document._meta.db_table}" ' # noqa: SLF001
615+
"IN SHARE ROW EXCLUSIVE MODE;"
616+
)
617+
610618
obj = models.Document.add_root(
611619
creator=self.request.user,
612620
**serializer.validated_data,
@@ -666,10 +674,19 @@ def trashbin(self, request, *args, **kwargs):
666674
permission_classes=[],
667675
url_path="create-for-owner",
668676
)
677+
@transaction.atomic
669678
def create_for_owner(self, request):
670679
"""
671680
Create a document on behalf of a specified owner (pre-existing user or invited).
672681
"""
682+
683+
# locks the table to ensure safe concurrent access
684+
with connection.cursor() as cursor:
685+
cursor.execute(
686+
f'LOCK TABLE "{models.Document._meta.db_table}" ' # noqa: SLF001
687+
"IN SHARE ROW EXCLUSIVE MODE;"
688+
)
689+
673690
# Deserialize and validate the data
674691
serializer = serializers.ServerCreateDocumentSerializer(data=request.data)
675692
if not serializer.is_valid():
@@ -775,7 +792,12 @@ def children(self, request, *args, **kwargs):
775792
serializer.is_valid(raise_exception=True)
776793

777794
with transaction.atomic():
778-
child_document = document.add_child(
795+
# "select_for_update" locks the table to ensure safe concurrent access
796+
locked_parent = models.Document.objects.select_for_update().get(
797+
pk=document.pk
798+
)
799+
800+
child_document = locked_parent.add_child(
779801
creator=request.user,
780802
**serializer.validated_data,
781803
)

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(

src/frontend/apps/e2e/.eslintrc.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
module.exports = {
22
root: true,
3-
extends: ["impress/playwright"],
3+
extends: ['impress/playwright'],
44
parserOptions: {
55
tsconfigRootDir: __dirname,
6-
project: ["./tsconfig.json"],
6+
project: ['./tsconfig.json'],
77
},
8-
ignorePatterns: ["node_modules"],
8+
ignorePatterns: ['node_modules'],
99
};

src/frontend/apps/e2e/__tests__/app-impress/common.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ export const createDoc = async (
7878
});
7979

8080
const input = page.getByLabel('doc title input');
81+
await expect(input).toBeVisible();
8182
await expect(input).toHaveText('');
8283
await input.click();
8384

src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,11 +79,14 @@ test.describe('Config', () => {
7979

8080
test('it checks that collaboration server is configured from config endpoint', async ({
8181
page,
82-
browserName,
8382
}) => {
8483
await page.goto('/');
8584

86-
void createDoc(page, 'doc-collaboration', browserName, 1);
85+
void page
86+
.getByRole('button', {
87+
name: 'New doc',
88+
})
89+
.click();
8790

8891
const webSocket = await page.waitForEvent('websocket', (webSocket) => {
8992
return webSocket.url().includes('ws://localhost:4444/collaboration/ws/');

src/frontend/apps/e2e/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@
1212
"test:ui::chromium": "yarn test:ui --project=chromium"
1313
},
1414
"devDependencies": {
15-
"@playwright/test": "1.50.1",
15+
"@playwright/test": "1.52.0",
1616
"@types/node": "*",
17-
"@types/pdf-parse": "1.1.4",
17+
"@types/pdf-parse": "1.1.5",
1818
"eslint-config-impress": "*",
1919
"typescript": "*"
2020
},

0 commit comments

Comments
 (0)