From e421a1b55ca2c4125e64cf152d3ce64adbda8873 Mon Sep 17 00:00:00 2001 From: lizerk Date: Mon, 20 Jul 2026 14:52:17 +0300 Subject: [PATCH] fix(db): scope row locks to base records --- backend/api/auth/services.py | 4 ++-- backend/api/v1/finance/admin_views.py | 17 +++++++++++++---- backend/api/v1/tasks/views.py | 2 +- backend/finance/payroll/attendance.py | 8 +++++--- backend/finance/payroll/services.py | 6 +++--- 5 files changed, 24 insertions(+), 13 deletions(-) diff --git a/backend/api/auth/services.py b/backend/api/auth/services.py index 4937b69a..23fd60a2 100644 --- a/backend/api/auth/services.py +++ b/backend/api/auth/services.py @@ -178,7 +178,7 @@ def create_qr_login_token( def exchange_qr_login_token(*, raw_token: str, request) -> tuple[str, str]: token_hash = hash_qr_login_token(raw_token) qr_token = ( - QrLoginToken.objects.select_for_update() + QrLoginToken.objects.select_for_update(of=("self",)) .select_related("user") .filter(token_hash=token_hash) .first() @@ -330,7 +330,7 @@ def poll_qr_login_request( request, ) -> dict[str, str | None]: qr_request = ( - QrLoginRequest.objects.select_for_update() + QrLoginRequest.objects.select_for_update(of=("self",)) .select_related("approved_by") .filter( client_secret_hash=hash_qr_login_request_client_secret(raw_client_secret), diff --git a/backend/api/v1/finance/admin_views.py b/backend/api/v1/finance/admin_views.py index 5aaa024b..9d6cfdd7 100644 --- a/backend/api/v1/finance/admin_views.py +++ b/backend/api/v1/finance/admin_views.py @@ -150,7 +150,9 @@ def _audit_api_mutation(*, actor, action, instance, period=None, metadata=None): def _lock_period_accepting_draft_edits(period_id): period = get_object_or_404( - PayrollPeriod.objects.select_for_update().select_related("current_run"), + PayrollPeriod.objects.select_for_update(of=("self",)).select_related( + "current_run" + ), pk=period_id, ) if period.status == PayrollPeriodStatus.CLOSED: @@ -699,7 +701,12 @@ class DraftDetailView(PayrollAdminAPIView): period = _lock_period_accepting_draft_edits(reference.period_id) else: period = None - instance = get_object_or_404(scoped_queryset.select_for_update()) + # Read serializers join nullable audit/revision relations. PostgreSQL + # cannot apply FOR UPDATE to the nullable side of those outer joins, + # and only the draft itself needs to be locked here. + instance = get_object_or_404( + scoped_queryset.select_for_update(of=("self",)) + ) if period is not None: instance._state.fields_cache["period"] = period self.validate_period_update(instance, period) @@ -1001,7 +1008,9 @@ class EmployeePayRateReviseView(PayrollAdminAPIView): def post(self, request, pk): self.require_permission(request, PAYROLL_PERMISSIONS["manage_inputs"]) source = get_object_or_404( - EmployeePayRate.objects.select_for_update().select_related("employee"), + EmployeePayRate.objects.select_for_update(of=("self",)).select_related( + "employee" + ), pk=pk, status=ApprovalStatus.APPROVED, ) @@ -1071,7 +1080,7 @@ class PayrollWorkRecordReviseView(PayrollAdminAPIView): ) period = _lock_period_accepting_draft_edits(reference.period_id) source = get_object_or_404( - PayrollWorkRecord.objects.select_for_update().select_related( + PayrollWorkRecord.objects.select_for_update(of=("self",)).select_related( "period", "employee" ), pk=pk, diff --git a/backend/api/v1/tasks/views.py b/backend/api/v1/tasks/views.py index 1e580a25..78effd06 100644 --- a/backend/api/v1/tasks/views.py +++ b/backend/api/v1/tasks/views.py @@ -600,7 +600,7 @@ class TaskViewSet(viewsets.ModelViewSet): visible_task = self.get_object() with transaction.atomic(): task = ( - Task.objects.select_for_update() + Task.objects.select_for_update(of=("self",)) .select_related("board", "column", "assignee") .get(id=visible_task.id) ) diff --git a/backend/finance/payroll/attendance.py b/backend/finance/payroll/attendance.py index e8caab2e..61f67662 100644 --- a/backend/finance/payroll/attendance.py +++ b/backend/finance/payroll/attendance.py @@ -433,8 +433,10 @@ def build_attendance_work_preview( "employee", "employee__position", "created_by" ) if lock: - attendance = attendance.select_for_update() - work = work.select_for_update() + # Both projections contain nullable outer joins (analysis run and + # employee position). Lock only the source rows being imported. + attendance = attendance.select_for_update(of=("self",)) + work = work.select_for_update(of=("self",)) attendance_records = list(attendance.order_by("employee_id", "date", "id")) work_records = list(work.order_by("employee_id", "revision", "id")) @@ -594,7 +596,7 @@ def apply_attendance_work_preview( ) period = ( - PayrollPeriod.objects.select_for_update() + PayrollPeriod.objects.select_for_update(of=("self",)) .select_related("current_run") .get(pk=period_id) ) diff --git a/backend/finance/payroll/services.py b/backend/finance/payroll/services.py index 9fbe8fc0..465d8626 100644 --- a/backend/finance/payroll/services.py +++ b/backend/finance/payroll/services.py @@ -757,7 +757,7 @@ def bulk_set_point_rate( selected_employee_ids = sorted(set(employee_ids)) rates = list( - EmployeePayRate.objects.select_for_update() + EmployeePayRate.objects.select_for_update(of=("self",)) .filter( employee_id__in=selected_employee_ids, rate_code=base_rate_code(), @@ -984,7 +984,7 @@ def bulk_set_pay_rate( ).order_by("pk") } rates = list( - EmployeePayRate.objects.select_for_update() + EmployeePayRate.objects.select_for_update(of=("self",)) .filter( employee_id__in=employees, rate_code=base_rate_code(), @@ -1374,7 +1374,7 @@ def save_own_daily_work_entry( "Для сохранения выработки необходимо войти в систему.", ) period = ( - PayrollPeriod.objects.select_for_update() + PayrollPeriod.objects.select_for_update(of=("self",)) .select_related("current_run") .get(pk=period_id) )