-
Notifications
You must be signed in to change notification settings - Fork 9
Reduce DB contention: skip project UPDATE when registering new files #1813
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
base: dev
Are you sure you want to change the base?
Changes from all commits
e2017cc
6044719
a3a7f00
42a9c3b
fcd7e14
8901707
71f7669
101a676
b90bcd9
385938f
931a2d7
5e5c4ac
069d499
f232cd5
78733de
da53d04
d15775c
a7d9f63
cfa4f74
7eb55aa
0e72058
474f6ae
6c5312c
827af52
6b9d3c4
1221181
480ad7d
2c80c7b
0299d99
6ee30ac
f3a4fce
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -140,9 +140,10 @@ def return_items(self, data, **kwargs): | |
| ) | ||
|
|
||
| project = data.get("project_row") | ||
| # Update foreign keys | ||
| project.file_versions.append(new_version) | ||
| project.files.append(new_file) | ||
| # Set FKs directly so we do not modify the project row (avoids UPDATE on projects | ||
| # and reduces lock contention during concurrent POST /file/new). | ||
| new_file.project_id = project.id | ||
| new_version.project_id = project.id | ||
|
Comment on lines
+145
to
+146
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.
By setting Useful? React with 👍 / 👎.
Member
Author
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. To clarify, the race actually existed pre-PR too: the UPDATE projects from the cascade serializes commits but not the SELECT-vs-INSERT ordering, so two workers can both pass verify_file_not_exists and both insert under REPEATABLE READ. This PR widens the race rather than introducing it. |
||
| new_file.versions.append(new_version) | ||
|
|
||
| return new_file | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,147 @@ | ||
| # IMPORTS ################################################################################ IMPORTS # | ||
|
|
||
| # Standard library | ||
| import http | ||
| import datetime | ||
|
|
||
| # Installed | ||
| import freezegun | ||
| import sqlalchemy | ||
| from unittest.mock import patch | ||
|
|
||
| # Own | ||
| from dds_web import db | ||
| import tests | ||
| from tests.test_files_new import project_row | ||
|
|
||
|
|
||
| # TESTS #################################################################################### TESTS # | ||
|
|
||
| # ProjectUploadComplete - "/proj/upload/complete" | ||
|
|
||
|
|
||
| def test_proj_upload_complete_updates_timestamp(client): | ||
| """POST /proj/upload/complete refreshes date_updated and last_updated_by.""" | ||
| project_1 = project_row(project_id="file_testing_project") | ||
| assert project_1 | ||
|
|
||
| frozen_before = datetime.datetime(2000, 1, 1, 12, 0, 0) | ||
| frozen_after = datetime.datetime(2000, 1, 2, 12, 0, 0) | ||
|
|
||
| with freezegun.freeze_time(frozen_before): | ||
| project_1.date_updated = frozen_before | ||
| db.session.commit() | ||
|
|
||
| token = tests.UserAuth(tests.USER_CREDENTIALS["unitadmin"]).token(client) | ||
|
|
||
| with freezegun.freeze_time(frozen_after): | ||
| response = client.post( | ||
| tests.DDSEndpoint.PROJ_UPLOAD_COMPLETE, | ||
| headers=token, | ||
| query_string={"project": "file_testing_project"}, | ||
| ) | ||
| assert response.status_code == http.HTTPStatus.OK | ||
| assert response.json.get("message") == "Project upload timestamp updated." | ||
|
|
||
| db.session.refresh(project_1) | ||
| assert project_1.date_updated == frozen_after | ||
| assert project_1.last_updated_by == "unitadmin" | ||
|
|
||
|
|
||
| def test_proj_upload_complete_unit_personnel_allowed(client): | ||
| """Unit Personnel (non-admin unit user) can call POST /proj/upload/complete.""" | ||
| response = client.post( | ||
| tests.DDSEndpoint.PROJ_UPLOAD_COMPLETE, | ||
| headers=tests.UserAuth(tests.USER_CREDENTIALS["unituser"]).token(client), | ||
| query_string={"project": "file_testing_project"}, | ||
| ) | ||
| assert response.status_code == http.HTTPStatus.OK | ||
|
|
||
|
|
||
| def test_proj_upload_complete_unauthorized_roles_denied(client): | ||
| """Researcher and Project Owner cannot call POST /proj/upload/complete.""" | ||
| for role in ("researcher", "projectowner"): | ||
| response = client.post( | ||
| tests.DDSEndpoint.PROJ_UPLOAD_COMPLETE, | ||
| headers=tests.UserAuth(tests.USER_CREDENTIALS[role]).token(client), | ||
| query_string={"project": "file_testing_project"}, | ||
| ) | ||
| assert ( | ||
| response.status_code == http.HTTPStatus.FORBIDDEN | ||
| ), f"Expected 403 for role '{role}', got {response.status_code}" | ||
|
|
||
|
|
||
| def test_proj_upload_complete_missing_project(client): | ||
| """POST /proj/upload/complete returns 400 when project query param is missing.""" | ||
| response = client.post( | ||
| tests.DDSEndpoint.PROJ_UPLOAD_COMPLETE, | ||
| headers=tests.UserAuth(tests.USER_CREDENTIALS["unitadmin"]).token(client), | ||
| ) | ||
| assert response.status_code == http.HTTPStatus.BAD_REQUEST | ||
| assert response.json.get("project", {}).get("message") == "Project ID required." | ||
|
|
||
|
|
||
| def test_proj_upload_complete_db_failure(client): | ||
| """POST /proj/upload/complete returns 500 on database error.""" | ||
| token = tests.UserAuth(tests.USER_CREDENTIALS["unitadmin"]).token(client) | ||
|
|
||
| with patch("dds_web.db.session.commit") as mock_commit: | ||
| mock_commit.side_effect = sqlalchemy.exc.SQLAlchemyError() | ||
|
|
||
| response = client.post( | ||
| tests.DDSEndpoint.PROJ_UPLOAD_COMPLETE, | ||
| headers=token, | ||
| query_string={"project": "file_testing_project"}, | ||
| ) | ||
| assert response.status_code == http.HTTPStatus.INTERNAL_SERVER_ERROR | ||
| assert "Failed to update project timestamp after upload" in response.json["message"] | ||
|
|
||
|
|
||
| def test_proj_upload_complete_no_update_if_available(client, boto3_session): | ||
| """POST /proj/upload/complete returns 400 when project status is Available.""" | ||
| token = tests.UserAuth(tests.USER_CREDENTIALS["unitadmin"]).token(client) | ||
|
|
||
| # Move project to Available | ||
| response = client.post( | ||
| tests.DDSEndpoint.PROJECT_STATUS, | ||
| headers=token, | ||
| query_string={"project": "file_testing_project"}, | ||
| json={"new_status": "Available"}, | ||
| ) | ||
| assert response.status_code == http.HTTPStatus.OK | ||
|
|
||
| response = client.post( | ||
| tests.DDSEndpoint.PROJ_UPLOAD_COMPLETE, | ||
| headers=token, | ||
| query_string={"project": "file_testing_project"}, | ||
| ) | ||
| assert response.status_code == http.HTTPStatus.BAD_REQUEST | ||
|
|
||
|
|
||
| def test_proj_upload_complete_no_update_if_expired(client, boto3_session, mock_queue_redis): | ||
| """POST /proj/upload/complete returns 400 when project status is Expired.""" | ||
| token = tests.UserAuth(tests.USER_CREDENTIALS["unitadmin"]).token(client) | ||
|
|
||
| # Move project to Available then Expired | ||
| response = client.post( | ||
| tests.DDSEndpoint.PROJECT_STATUS, | ||
| headers=token, | ||
| query_string={"project": "file_testing_project"}, | ||
| json={"new_status": "Available"}, | ||
| ) | ||
| assert response.status_code == http.HTTPStatus.OK | ||
|
|
||
| response = client.post( | ||
| tests.DDSEndpoint.PROJECT_STATUS, | ||
| headers=token, | ||
| query_string={"project": "file_testing_project"}, | ||
| json={"new_status": "Expired"}, | ||
| ) | ||
| assert response.status_code == http.HTTPStatus.OK | ||
|
|
||
| response = client.post( | ||
| tests.DDSEndpoint.PROJ_UPLOAD_COMPLETE, | ||
| headers=token, | ||
| query_string={"project": "file_testing_project"}, | ||
| ) | ||
| assert response.status_code == http.HTTPStatus.BAD_REQUEST |
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.
Not a blocker:
We don't need both
above and
Not a huge deal, but might be confusing in the future.
There are 2 alternatives I think (I tested locally) work the same way without us repeating the project connection:
new_version.project_id = project.idrow and changeprojecttoproject.idin the version:project_id=projectin the version definition and keep thenew_version.project_id = project.id.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.
Thanks @i-oden , all your comments are addressed now