From 95b74bc5e5bfe75d6f745e611c402d009e9901c3 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 18:14:33 +0400 Subject: [PATCH] =?UTF-8?q?fix(projects):=20advisory=20lock=20=D0=B8=20?= =?UTF-8?q?=D0=BE=D1=88=D0=B8=D0=B1=D0=BA=D0=B0=20=D0=BF=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=BF=D0=BE=D0=BB=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=81=D1=87?= =?UTF-8?q?=D1=91=D1=82=D1=87=D0=B8=D0=BA=D0=B0=20technical=5Fnumber=20?= =?UTF-8?q?=D0=B2=20add=5Fproject=20(AURORA-1831)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .../0050_project_technical_number_lock.py | 15 +++ .../0050_project_technical_number_lock.sql | 96 +++++++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 api/alembic/versions/0050_project_technical_number_lock.py create mode 100644 api/alembic/versions/sql/0050_project_technical_number_lock.sql diff --git a/api/alembic/versions/0050_project_technical_number_lock.py b/api/alembic/versions/0050_project_technical_number_lock.py new file mode 100644 index 0000000..d93500a --- /dev/null +++ b/api/alembic/versions/0050_project_technical_number_lock.py @@ -0,0 +1,15 @@ +from sql_migration import run_sql_file_migration + + +revision = "0050" +down_revision = "0049" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + run_sql_file_migration("0050_project_technical_number_lock.sql") + + +def downgrade() -> None: + pass diff --git a/api/alembic/versions/sql/0050_project_technical_number_lock.sql b/api/alembic/versions/sql/0050_project_technical_number_lock.sql new file mode 100644 index 0000000..7236be4 --- /dev/null +++ b/api/alembic/versions/sql/0050_project_technical_number_lock.sql @@ -0,0 +1,96 @@ +-- AURORA-1831: гонка technical_number при параллельном создании проектов. +-- v3.add_project: копия версии из 0018 + advisory lock перед вычислением счётчика +-- и понятная ошибка при переполнении счётчика (больше 99 проектов на оргединицу). + +CREATE OR REPLACE FUNCTION v3.add_project(p_name character varying, p_year integer, p_org_unit_id integer, p_level character varying DEFAULT 'project'::character varying, p_parent_id integer DEFAULT NULL::integer, p_project_type character varying DEFAULT NULL::character varying, p_vsp_format character varying DEFAULT NULL::character varying, p_placement_type character varying DEFAULT NULL::character varying, p_object_address character varying DEFAULT NULL::character varying, p_staff_count integer DEFAULT NULL::integer, p_total_area numeric DEFAULT NULL::numeric, p_system_code character varying DEFAULT NULL::character varying) + RETURNS TABLE(project_id integer, limit_report_id integer, current_expenses_report_id integer) + LANGUAGE plpgsql +AS $function$ +DECLARE + v_pid INT; + v_lim INT; + v_cur INT; + v_seq INT; + v_technical_number VARCHAR; +BEGIN + IF p_name IS NULL OR length(trim(p_name)) = 0 THEN + RAISE EXCEPTION 'p_name обязателен'; + END IF; + IF p_year IS NULL THEN + RAISE EXCEPTION 'p_year обязателен'; + END IF; + IF p_org_unit_id IS NULL THEN + RAISE EXCEPTION 'p_org_unit_id обязателен'; + END IF; + IF NOT EXISTS (SELECT 1 FROM v3.org_unit ou WHERE id = p_org_unit_id AND ou.is_active) THEN + RAISE EXCEPTION 'org_unit id=% не существует', p_org_unit_id; + END IF; + IF p_parent_id IS NOT NULL AND + NOT EXISTS (SELECT 1 FROM v3.project WHERE id = p_parent_id AND level = 'program') THEN + RAISE EXCEPTION 'parent project id=% не существует или не имеет level=program', p_parent_id; + END IF; + + -- AURORA-1831: сериализуем выдачу номера внутри одной оргединицы. Без блокировки + -- две параллельные транзакции читают один и тот же MAX и падают на уникальном индексе. + -- Блокировка снимается на COMMIT/ROLLBACK. Решение опирается на READ COMMITTED + -- (приложение не меняет изоляцию): после получения блокировки SELECT MAX берёт новый + -- снимок и видит строку, закоммиченную предыдущим владельцем. При REPEATABLE READ + -- и выше этого недостаточно. + PERFORM pg_advisory_xact_lock(hashtext('v3.project.technical_number'), p_org_unit_id); + + -- Порядковый счётчик проекта для данного РФ/ВСП (org_unit) + SELECT COALESCE(MAX(right(technical_number, 2)::int), 0) + 1 + INTO v_seq + FROM v3.project + WHERE org_unit_id = p_org_unit_id + AND technical_number IS NOT NULL; + + IF v_seq > 99 THEN + RAISE EXCEPTION 'Исчерпан счётчик технических номеров проектов для org_unit id=% (максимум 99)', p_org_unit_id; + END IF; + + v_technical_number := to_char(now(), 'YYMM') || '_' + || lpad(p_org_unit_id::text, 4, '0') + || lpad(v_seq::text, 2, '0'); + + INSERT INTO v3.project ( + name, level, parent_id, org_unit_id, + project_type, vsp_format, placement_type, object_address, staff_count, total_area, + system_code, technical_number + ) VALUES ( + p_name, p_level, p_parent_id, p_org_unit_id, + p_project_type, p_vsp_format, p_placement_type, p_object_address, p_staff_count, p_total_area, + p_system_code, v_technical_number + ) RETURNING id INTO v_pid; + + -- Первый год: 2 отчёта (LIMIT + CURRENT_EXPENSES) + snapshot фаз + сетка статей. + SELECT y.limit_report_id, y.current_expenses_report_id + INTO v_lim, v_cur + FROM v3.add_project_year(v_pid, p_year, false) y; + + -- Аудит: PROJECT_CREATE — все заполненные поля + id отчётов + PERFORM v3.log_event( + 'PROJECT_CREATE', 'PROJECT', + jsonb_strip_nulls(jsonb_build_object( + 'project_id', v_pid, + 'name', p_name, + 'year', p_year, + 'level', p_level, + 'parent_id', p_parent_id, + 'org_unit_id', p_org_unit_id, + 'project_type', p_project_type, + 'vsp_format', p_vsp_format, + 'placement_type', p_placement_type, + 'object_address', p_object_address, + 'system_code', p_system_code, + 'technical_number', v_technical_number, + 'staff_count', p_staff_count, + 'total_area', p_total_area, + 'limit_report_id', v_lim, + 'current_expenses_report_id', v_cur + )) + ); + + RETURN QUERY SELECT v_pid, v_lim, v_cur; +END; +$function$;