Skip to content

Commit aadfb48

Browse files
authored
fix(submissions): return proper status code instead of 202 when lock is held DEV-349 (#5805)
### 📣 Summary Avoid returning HTTP 202 when an advisory lock prevents a task from running, and use a more appropriate status code to reflect the lock state. ### 📖 Description Previously, when an operation was skipped due to an advisory lock already being held (e.g., to prevent duplicate task execution), the API would return a 202 Accepted, which was misleading since the operation wasn't actually queued or performed. This PR changes the behavior to return a more accurate status code (423 Locked) when a lock is in place and prevents the task from running.
1 parent b0114e9 commit aadfb48

File tree

5 files changed

+54
-37
lines changed

5 files changed

+54
-37
lines changed

kobo/apps/openrosa/apps/api/tests/viewsets/test_xform_submission_api.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,22 +12,21 @@
1212
from django.core.files.uploadedfile import InMemoryUploadedFile
1313
from django.test.testcases import LiveServerTestCase
1414
from django_digest.test import DigestAuth
15+
from rest_framework import status
1516
from rest_framework.authtoken.models import Token
1617

1718
from kobo.apps.kobo_auth.shortcuts import User
18-
from kobo.apps.openrosa.apps.main.models import UserProfile
19-
from kobo.apps.openrosa.libs.tests.mixins.request_mixin import RequestMixin
20-
from rest_framework import status
21-
2219
from kobo.apps.openrosa.apps.api.tests.viewsets.test_abstract_viewset import (
2320
TestAbstractViewSet,
2421
)
2522
from kobo.apps.openrosa.apps.api.viewsets.xform_submission_api import XFormSubmissionApi
2623
from kobo.apps.openrosa.apps.logger.models import Attachment
2724
from kobo.apps.openrosa.apps.main import tests as main_tests
25+
from kobo.apps.openrosa.apps.main.models import UserProfile
2826
from kobo.apps.openrosa.libs.constants import CAN_ADD_SUBMISSIONS
29-
from kobo.apps.openrosa.libs.utils.guardian import assign_perm
27+
from kobo.apps.openrosa.libs.tests.mixins.request_mixin import RequestMixin
3028
from kobo.apps.openrosa.libs.utils import logger_tools
29+
from kobo.apps.openrosa.libs.utils.guardian import assign_perm
3130
from kobo.apps.openrosa.libs.utils.logger_tools import (
3231
OpenRosaResponseNotAllowed,
3332
OpenRosaTemporarilyUnavailable,
@@ -610,7 +609,7 @@ def test_post_concurrent_same_submissions(self):
610609
results[result] += 1
611610

612611
assert results[status.HTTP_201_CREATED] == 1
613-
assert results[status.HTTP_202_ACCEPTED] == DUPLICATE_SUBMISSIONS_COUNT - 1
612+
assert results[status.HTTP_423_LOCKED] == DUPLICATE_SUBMISSIONS_COUNT - 1
614613

615614

616615
def submit_data(identifier, survey_, username_, live_server_url, token_):

kobo/apps/openrosa/apps/logger/exceptions.py

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
from django.utils.translation import gettext_lazy as t
2+
3+
14
class AccountInactiveError(Exception):
25
pass
36

@@ -15,17 +18,17 @@ class BuildDbQueriesNoConfirmationProvidedError(Exception):
1518

1619

1720
class ConflictingAttachmentBasenameError(Exception):
18-
def __init__(self, message='Attachment with same name already exists'):
21+
def __init__(self, message=t('Attachment with same name already exists')):
1922
super().__init__(message)
2023

2124

2225
class ConflictingSubmissionUUIDError(Exception):
23-
def __init__(self, message='Submission with this instance ID already exists'):
26+
def __init__(self, message=t('Submission with this instance ID already exists')):
2427
super().__init__(message)
2528

2629

2730
class DuplicateInstanceError(Exception):
28-
def __init__(self, message='Duplicate Instance'):
31+
def __init__(self, message=t('Duplicate Instance')):
2932
super().__init__(message)
3033

3134

@@ -38,17 +41,17 @@ class FormInactiveError(Exception):
3841

3942

4043
class InstanceEmptyError(Exception):
41-
def __init__(self, message='Empty instance'):
44+
def __init__(self, message=t('Empty instance')):
4245
super().__init__(message)
4346

4447

4548
class InstanceInvalidUserError(Exception):
46-
def __init__(self, message='Could not determine the user'):
49+
def __init__(self, message=t('Could not determine the user')):
4750
super().__init__(message)
4851

4952

5053
class InstanceIdMissingError(Exception):
51-
def __init__(self, message='Could not determine the instance ID'):
54+
def __init__(self, message=t('Could not determine the instance ID')):
5255
super().__init__(message)
5356

5457

@@ -57,7 +60,12 @@ class InstanceMultipleNodeError(Exception):
5760

5861

5962
class InstanceParseError(Exception):
60-
def __init__(self, message='The instance could not be parsed'):
63+
def __init__(self, message=t('The instance could not be parsed')):
64+
super().__init__(message)
65+
66+
67+
class LockedSubmissionError(Exception):
68+
def __init__(self, message=t('Submission is currently being processed.')):
6169
super().__init__(message)
6270

6371

kobo/apps/openrosa/apps/logger/models/instance.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -202,9 +202,8 @@ def _set_uuid(self):
202202

203203
def _populate_root_uuid(self):
204204
if self.xml and not self.root_uuid:
205-
assert (
206-
root_uuid := get_root_uuid_from_xml(self.xml)
207-
), 'root_uuid should not be empty'
205+
root_uuid, _ = get_root_uuid_from_xml(self.xml)
206+
assert root_uuid, 'root_uuid should not be empty'
208207
self.root_uuid = root_uuid
209208

210209
def _populate_xml_hash(self):

kobo/apps/openrosa/apps/logger/xform_instance_parser.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,13 +81,17 @@ def get_uuid_from_xml(xml):
8181
return None
8282

8383

84-
def get_root_uuid_from_xml(xml):
84+
def get_root_uuid_from_xml(xml) -> tuple[str, bool]:
85+
"""
86+
Returns the value of <meta/rootUuid> from the given XML. If that node is missing,
87+
falls back to <meta/instanceID>. The boolean indicates whether a fallback was used.
88+
"""
8589
root_uuid = get_meta_from_xml(xml, 'rootUuid')
8690
if root_uuid:
87-
return remove_uuid_prefix(root_uuid)
91+
return remove_uuid_prefix(root_uuid), False
8892

8993
# If no rootUuid, fall back to instanceID
90-
return get_uuid_from_xml(xml)
94+
return get_uuid_from_xml(xml), True
9195

9296

9397
def get_submission_date_from_xml(xml) -> Optional[datetime]:

kobo/apps/openrosa/libs/utils/logger_tools.py

Lines changed: 25 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@
3434
from django.utils.encoding import DjangoUnicodeDecodeError, smart_str
3535
from django.utils.translation import gettext as t
3636
from modilabs.utils.subprocess_timeout import ProcessTimedOut
37+
from pyxform.errors import PyXFormError
38+
from pyxform.xform2json import create_survey_element_from_xml
3739
from rest_framework.exceptions import NotAuthenticated
3840

3941
from kobo.apps.openrosa.apps.logger.exceptions import (
@@ -47,6 +49,7 @@
4749
InstanceIdMissingError,
4850
InstanceInvalidUserError,
4951
InstanceMultipleNodeError,
52+
LockedSubmissionError,
5053
TemporarilyUnavailableError,
5154
)
5255
from kobo.apps.openrosa.apps.logger.models import Attachment, Instance, XForm
@@ -67,6 +70,7 @@
6770
clean_and_parse_xml,
6871
get_abbreviated_xpath,
6972
get_deprecated_uuid_from_xml,
73+
get_root_uuid_from_xml,
7074
get_submission_date_from_xml,
7175
get_uuid_from_xml,
7276
get_xform_media_question_xpaths,
@@ -83,8 +87,6 @@
8387
from kpi.utils.hash import calculate_hash
8488
from kpi.utils.mongo_helper import MongoHelper
8589
from kpi.utils.object_permission import get_database_user
86-
from pyxform.errors import PyXFormError
87-
from pyxform.xform2json import create_survey_element_from_xml
8890

8991
OPEN_ROSA_VERSION_HEADER = 'X-OpenRosa-Version'
9092
HTTP_OPEN_ROSA_VERSION_HEADER = 'HTTP_X_OPENROSA_VERSION'
@@ -182,11 +184,10 @@ def create_instance(
182184
183185
Raises:
184186
InstanceIdMissingError: If no valid UUID is found in the XML submission.
185-
DuplicateInstanceError: If an advisory lock is not acquired, or if there
186-
is a submission with the same XML hash and user
187+
DuplicateInstanceError: If there is a submission with the same XML hash and user
187188
without any new attachments.
188-
ConflictingSubmissionUUIDError: If the same UUID is submitted with
189-
differing content.
189+
ConflictingSubmissionUUIDError: If the same UUID already exists or being
190+
processed.
190191
PermissionDenied: If the submission fails permission checks.
191192
"""
192193

@@ -198,15 +199,19 @@ def create_instance(
198199
xform = get_xform_from_submission(xml, username, uuid)
199200
check_submission_permissions(request, xform)
200201

201-
# get new and deprecated uuid's
202-
new_uuid = get_uuid_from_xml(xml)
202+
# get root uuid
203+
root_uuid, fallback_on_uuid = get_root_uuid_from_xml(xml)
204+
new_uuid = root_uuid if fallback_on_uuid else get_uuid_from_xml(xml)
203205
if not new_uuid:
204206
raise InstanceIdMissingError
205207

206208
with kc_transaction_atomic():
207-
with get_instance_lock(xml_hash, new_uuid, xform.id) as lock_acquired:
209+
with get_instance_lock(root_uuid, xform.id) as lock_acquired:
208210
if not lock_acquired:
209-
raise DuplicateInstanceError
211+
raise LockedSubmissionError(
212+
f'Submission {root_uuid} is currently being processed. '
213+
f'Try again later.'
214+
)
210215

211216
# Check for an existing instance
212217
existing_instance = Instance.objects.filter(
@@ -256,21 +261,19 @@ def dict2xform(submission: dict, xform_id_string: str) -> str:
256261

257262

258263
@contextlib.contextmanager
259-
def get_instance_lock(xml_hash: str, submission_uuid: str, xform_id: int) -> bool:
264+
def get_instance_lock(submission_uuid: str, xform_id: int) -> bool:
260265
"""
261266
Acquires a PostgreSQL advisory lock to prevent race conditions when
262267
processing form submissions. This ensures that submissions with the same
263-
unique identifiers (xform_id, submission_uuid, and xml_hash) are handled
268+
unique identifiers (xform_id, submission_uuid) are handled
264269
sequentially, preventing duplicate records in the database.
265270
266271
A unique integer lock key (int_lock) is generated by creating a SHAKE-128
267272
hash of the unique identifiers, truncating it to 7 bytes, and converting it
268273
to an integer.
269274
"""
270275
int_lock = int.from_bytes(
271-
hashlib.shake_128(
272-
f'{xform_id}!!{submission_uuid}!!{xml_hash}'.encode()
273-
).digest(7), 'little'
276+
hashlib.shake_128(f'{xform_id}!!{submission_uuid}'.encode()).digest(7), 'little'
274277
)
275278
acquired = False
276279

@@ -389,9 +392,13 @@ def status_code(self):
389392
response.status_code = 409
390393
response['Location'] = request.build_absolute_uri(request.path)
391394
result.http_error_response = response
392-
except DuplicateInstanceError:
393-
result.error = t('Duplicate submission')
394-
response = OpenRosaResponse(result.error)
395+
except LockedSubmissionError as e:
396+
response = OpenRosaResponse(str(e))
397+
response.status_code = 423
398+
response['Location'] = request.build_absolute_uri(request.path)
399+
result.http_error_response = response
400+
except DuplicateInstanceError as e:
401+
response = OpenRosaResponse(str(e))
395402
response.status_code = 202
396403
response['Location'] = request.build_absolute_uri(request.path)
397404
result.http_error_response = response

0 commit comments

Comments
 (0)