From 663f45ee044250b13043153fcd68abca809e13a0 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 28 Oct 2026 18:14:33 +0400 Subject: [PATCH] =?UTF-8?q?test(projects):=20=D0=B3=D0=BE=D0=BD=D0=BA?= =?UTF-8?q?=D0=B0=20technical=5Fnumber=20=D0=B8=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=D0=B5=20=D1=81=D1=87?= =?UTF-8?q?=D1=91=D1=82=D1=87=D0=B8=D0=BA=D0=B0=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 --- ...st_project_technical_number_concurrency.py | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 api/tests/integration/test_project_technical_number_concurrency.py diff --git a/api/tests/integration/test_project_technical_number_concurrency.py b/api/tests/integration/test_project_technical_number_concurrency.py new file mode 100644 index 0000000..01c1ebf --- /dev/null +++ b/api/tests/integration/test_project_technical_number_concurrency.py @@ -0,0 +1,131 @@ +"""AURORA-1831: гонка technical_number в v3.add_project и переполнение счётчика. + +Тест гонки коммитит в общую тестовую БД (два независимых соединения, фикстура +client с общей сессией не подходит), поэтому создаваемые проекты удаляются в finally. +Тест переполнения ничего не коммитит: всё откатывается. +""" +import asyncio +import re + +import pytest +from sqlalchemy import text +from sqlalchemy.exc import DBAPIError +from sqlalchemy.ext.asyncio import create_async_engine + +from src.core.config import settings + +ORG_UNIT_ID = 2 # в фикстуре у этой оргединицы нет проектов +ADMIN_ID = 1 + +CALL_ADD_PROJECT = text( + "SELECT project_id FROM v3.add_project(:name, 2026, :org, 'project', NULL, " + "'Открытие ВСП', 'Типовой', 'own', 'Тестовая улица 1')" +) + + +async def _begin_as_admin(conn): + await conn.execute( + text("SELECT set_config('app.user_id', :uid, true)"), {"uid": str(ADMIN_ID)} + ) + + +async def _wait_for_lock_waiter(monitor_conn, timeout: float = 10.0): + """Ждёт, пока какой-либо бэкенд встанет в ожидание блокировки (pg_stat_activity).""" + deadline = asyncio.get_running_loop().time() + timeout + while asyncio.get_running_loop().time() < deadline: + row = await monitor_conn.execute( + text( + "SELECT count(*) FROM pg_stat_activity " + "WHERE datname = current_database() AND wait_event_type = 'Lock' " + "AND pid <> pg_backend_pid()" + ) + ) + if row.scalar_one() > 0: + return + await asyncio.sleep(0.02) + raise AssertionError("вторая транзакция не встала в ожидание блокировки") + + +async def _technical_number(conn, project_id: int) -> str: + row = await conn.execute( + text("SELECT technical_number FROM v3.project WHERE id = :id"), {"id": project_id} + ) + return row.scalar_one() + + +@pytest.mark.asyncio +async def test_parallel_add_project_gets_distinct_sequential_numbers(): + engine = create_async_engine(settings.DATABASE_URL, echo=False) + created: list[int] = [] + conn_a = await engine.connect() + conn_b = await engine.connect() + monitor = await engine.connect() + try: + trans_a = await conn_a.begin() + await _begin_as_admin(conn_a) + pid_a = ( + await conn_a.execute(CALL_ADD_PROJECT, {"name": "PT_conc_a", "org": ORG_UNIT_ID}) + ).scalar_one() + created.append(pid_a) + + async def run_b(): + async with conn_b.begin(): + await _begin_as_admin(conn_b) + pid = ( + await conn_b.execute( + CALL_ADD_PROJECT, {"name": "PT_conc_b", "org": ORG_UNIT_ID} + ) + ).scalar_one() + created.append(pid) + return pid + + task_b = asyncio.create_task(run_b()) + # До правки B упирается в unique index, после правки - в advisory lock. + # В обоих случаях он встаёт в ожидание блокировки до COMMIT транзакции A. + await _wait_for_lock_waiter(monitor) + await trans_a.commit() + pid_b = await asyncio.wait_for(task_b, timeout=10) + + async with engine.connect() as check: + tn_a = await _technical_number(check, pid_a) + tn_b = await _technical_number(check, pid_b) + assert tn_a != tn_b + assert re.fullmatch(r"\d{4}_0002\d\d", tn_a) + assert int(tn_b[-2:]) == int(tn_a[-2:]) + 1 + finally: + await conn_a.close() + await conn_b.close() + await monitor.close() + async with engine.begin() as cleanup: + await _begin_as_admin(cleanup) + for pid in created: + await cleanup.execute(text("SELECT v3.del_project(:id)"), {"id": pid}) + await engine.dispose() + + +@pytest.mark.asyncio +async def test_add_project_counter_overflow_raises_clear_error(): + engine = create_async_engine(settings.DATABASE_URL, echo=False) + try: + async with engine.connect() as conn: + trans = await conn.begin() + try: + await _begin_as_admin(conn) + await conn.execute( + text( + "INSERT INTO v3.project (name, level, org_unit_id, technical_number, " + "project_type, vsp_format, placement_type, object_address) " + "VALUES ('PT_overflow', 'project', :org, '2601_000299', " + "'Открытие ВСП', 'Типовой', 'own', 'Тестовая улица 1')" + ), + {"org": ORG_UNIT_ID}, + ) + with pytest.raises(DBAPIError) as exc_info: + await conn.execute( + CALL_ADD_PROJECT, {"name": "PT_overflow_next", "org": ORG_UNIT_ID} + ) + assert "счётчик" in str(exc_info.value.orig) + finally: + await trans.rollback() + finally: + await engine.dispose()