From de2bd67dbdb8b7cef65735827fb71fcf9c940c1e Mon Sep 17 00:00:00 2001 From: Fedor Date: Thu, 30 Jul 2026 18:35:12 +0300 Subject: [PATCH 1/3] add backend postgres ci --- .github/workflows/backend-postgres-ci.yml | 117 ++++++++++++++++++++++ docs/backend-postgres-ci.md | 108 ++++++++++++++++++++ procollab/settings_ci.py | 50 +++++++++ 3 files changed, 275 insertions(+) create mode 100644 .github/workflows/backend-postgres-ci.yml create mode 100644 docs/backend-postgres-ci.md create mode 100644 procollab/settings_ci.py diff --git a/.github/workflows/backend-postgres-ci.yml b/.github/workflows/backend-postgres-ci.yml new file mode 100644 index 00000000..534f7c7c --- /dev/null +++ b/.github/workflows/backend-postgres-ci.yml @@ -0,0 +1,117 @@ +# Roadmap: DEV-074 +name: Backend PostgreSQL CI + +on: + pull_request: + branches: + - master + push: + branches: + - master + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: backend-postgres-ci-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + postgres-tests: + name: Backend PostgreSQL CI + runs-on: ubuntu-latest + timeout-minutes: 30 + + services: + postgres: + image: postgres:15-alpine + env: + POSTGRES_DB: procollab_ci + POSTGRES_USER: procollab_ci + POSTGRES_PASSWORD: procollab_ci_password + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U procollab_ci -d procollab_ci" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + env: + PYTHONUNBUFFERED: "1" + PYTHONDONTWRITEBYTECODE: "1" + DJANGO_SETTINGS_MODULE: procollab.settings_ci + DJANGO_SECRET_KEY: ci-only-not-production-secret + DEBUG: "False" + DATABASE_NAME: procollab_ci + DATABASE_USER: procollab_ci + DATABASE_PASSWORD: procollab_ci_password + DATABASE_HOST: 127.0.0.1 + DATABASE_PORT: "5432" + ALLOW_REACT_DEV_DEMO_SEED: "False" + AUTOPOSTING_ON: "False" + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install dependencies + run: | + python -m pip install "poetry==1.2.2" + poetry config virtualenvs.create true + poetry install --no-interaction --no-ansi --no-root + + - name: Verify PostgreSQL connection and locking support + run: | + poetry run python - <<'PY' + import django + + django.setup() + + from django.db import connection + + connection.ensure_connection() + assert connection.vendor == "postgresql", connection.vendor + assert connection.features.has_select_for_update + print(f"Database vendor: {connection.vendor}") + print( + "select_for_update support: " + f"{connection.features.has_select_for_update}" + ) + PY + + - name: Check Django configuration + run: poetry run python manage.py check + + - name: Check Django models + run: poetry run python manage.py check --tag models + + - name: Check migration consistency + run: poetry run python manage.py makemigrations --check --dry-run + + - name: Apply migrations + run: poetry run python manage.py migrate --noinput + + - name: Check CI settings formatting + run: poetry run black --check procollab/settings_ci.py + + - name: Lint CI settings + run: poetry run flake8 procollab/settings_ci.py + + - name: Run PostgreSQL locking and constraint tests + run: | + poetry run python manage.py test \ + partner_programs.tests.test_expert_evaluation_api.ConcurrentEvaluationSubmitTests \ + partner_programs.tests.test_application_team_models.ApplicationTeamDatabaseConstraintTests \ + partner_programs.tests.test_application_team_service.ApplicationTeamServiceSequentialConflictTests \ + partner_programs.tests.test_program_participation_policy.PartnerProgramParticipationPolicyConstraintTests \ + --verbosity 2 + + - name: Run full backend test suite + run: poetry run python manage.py test --verbosity 1 diff --git a/docs/backend-postgres-ci.md b/docs/backend-postgres-ci.md new file mode 100644 index 00000000..4db97e55 --- /dev/null +++ b/docs/backend-postgres-ci.md @@ -0,0 +1,108 @@ +# Backend PostgreSQL CI + + + +Workflow `Backend PostgreSQL CI` проверяет backend на настоящем PostgreSQL, а не +на SQLite. Это важно для транзакционных блокировок, partial constraints и +поведения `select_for_update`, которые SQLite не воспроизводит. + +## Когда запускается + +Workflow запускается: + +- для pull request в `master`; +- для push в `master`; +- вручную через `workflow_dispatch`. + +Имя обязательной проверки: `Backend PostgreSQL CI`. + +## Что проверяется + +Job поднимает одноразовый PostgreSQL 15 и последовательно выполняет: + +1. установку зависимостей из существующего `poetry.lock`; +2. preflight-проверку подключения, backend `postgresql` и поддержки + `select_for_update`; +3. `manage.py check`; +4. `manage.py check --tag models`; +5. `makemigrations --check --dry-run`; +6. применение всех миграций; +7. Black и flake8 для `procollab/settings_ci.py`; +8. targeted-тесты блокировок и ограничений Evaluation, Application и Team; +9. полный набор backend-тестов. + +Любая ошибка останавливает job. Ослабляющие флаги, пропуск ошибок и fake-миграции +не используются. + +## Изоляция + +`procollab.settings_ci` подключается только через переменную +`DJANGO_SETTINGS_MODULE`. Он не меняет обычный `procollab.settings` и не +используется deployment-контурами. + +В CI применяются: + +- одноразовая база `procollab_ci` и тестовая база `test_procollab_ci`; +- `LocMemCache` вместо Redis; +- `InMemoryChannelLayer` вместо Redis Channels; +- локальный email backend; +- синхронное выполнение Celery-задач с пробросом исключений; +- быстрый MD5 password hasher; +- отключенный React dev demo seed. + +Логин и пароль PostgreSQL находятся прямо в workflow, потому что относятся +только к одноразовому service container. Это не production/dev credentials и не +секреты инфраструктуры. + +## Переменные окружения + +Workflow задает: + +- `DJANGO_SETTINGS_MODULE=procollab.settings_ci`; +- `DJANGO_SECRET_KEY` с тестовым значением; +- `DEBUG=False`; +- `DATABASE_NAME`, `DATABASE_USER`, `DATABASE_PASSWORD`, `DATABASE_HOST`, + `DATABASE_PORT`; +- `ALLOW_REACT_DEV_DEMO_SEED=False`; +- `AUTOPOSTING_ON=False`. + +## Локальный запуск + +Локальные проверки следует запускать только с отдельным одноразовым PostgreSQL. +Нельзя направлять `settings_ci` на существующую dev- или production-базу. + +Пример для PowerShell после запуска отдельного PostgreSQL: + +```powershell +$env:DJANGO_SETTINGS_MODULE = "procollab.settings_ci" +$env:DJANGO_SECRET_KEY = "local-ci-only-secret" +$env:DEBUG = "False" +$env:DATABASE_NAME = "procollab_ci" +$env:DATABASE_USER = "procollab_ci" +$env:DATABASE_PASSWORD = "procollab_ci_password" +$env:DATABASE_HOST = "127.0.0.1" +$env:DATABASE_PORT = "5432" +$env:ALLOW_REACT_DEV_DEMO_SEED = "False" +$env:AUTOPOSTING_ON = "False" + +poetry run python manage.py check +poetry run python manage.py migrate --noinput +poetry run python manage.py test --verbosity 1 +``` + +После проверки одноразовую базу или контейнер нужно удалить. + +## Типовые ошибки + +- `connection refused`: PostgreSQL еще не готов или указан неверный порт. +- `password authentication failed`: переменные пользователя и пароля не + совпадают с настройками одноразового PostgreSQL. +- `database does not exist`: не создана исходная база `procollab_ci`. +- `makemigrations --check` предлагает миграцию: модели и миграции расходятся. +- preflight сообщает не `postgresql`: выбран неверный settings module или + переопределены параметры базы. +- тест блокировок зависает или завершается по timeout: следует проверить порядок + транзакционных блокировок и конкурентные изменения, а не отключать тест. + +Workflow не выполняет deployment, не подключается к dev/production-сервисам и не +запускает команды на существующих базах. diff --git a/procollab/settings_ci.py b/procollab/settings_ci.py new file mode 100644 index 00000000..e2267071 --- /dev/null +++ b/procollab/settings_ci.py @@ -0,0 +1,50 @@ +# Roadmap: DEV-074 +# Изолированная конфигурация тестов backend на PostgreSQL в CI. +from .settings import * # noqa: F401,F403 + +from decouple import config + +DEBUG = False +SECURE_SSL_REDIRECT = False + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": config("DATABASE_NAME", default="procollab_ci", cast=str), + "USER": config("DATABASE_USER", default="procollab_ci", cast=str), + "PASSWORD": config( + "DATABASE_PASSWORD", + default="procollab_ci_password", + cast=str, + ), + "HOST": config("DATABASE_HOST", default="127.0.0.1", cast=str), + "PORT": config("DATABASE_PORT", default="5432", cast=str), + "TEST": { + "NAME": "test_procollab_ci", + }, + } +} + +CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + "LOCATION": "procollab-ci-cache", + } +} + +CHANNEL_LAYERS = { + "default": { + "BACKEND": "channels.layers.InMemoryChannelLayer", + } +} + +EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend" + +CELERY_TASK_ALWAYS_EAGER = True +CELERY_TASK_EAGER_PROPAGATES = True + +PASSWORD_HASHERS = [ + "django.contrib.auth.hashers.MD5PasswordHasher", +] + +ALLOW_REACT_DEV_DEMO_SEED = False From fe793514a4c113b9376e263c6730e6b0de3d0d98 Mon Sep 17 00:00:00 2001 From: Fedor Date: Thu, 30 Jul 2026 19:44:47 +0300 Subject: [PATCH 2/3] fix postgres row locking compatibility --- .github/workflows/backend-postgres-ci.yml | 4 + docs/backend-postgres-ci.md | 6 ++ mailing/tests/test_models_rendering.py | 13 ++-- partner_programs/services/team_invites.py | 80 ++++++++++++-------- partner_programs/services/team_management.py | 17 ++--- 5 files changed, 71 insertions(+), 49 deletions(-) diff --git a/.github/workflows/backend-postgres-ci.yml b/.github/workflows/backend-postgres-ci.yml index 534f7c7c..32719c3c 100644 --- a/.github/workflows/backend-postgres-ci.yml +++ b/.github/workflows/backend-postgres-ci.yml @@ -111,6 +111,10 @@ jobs: partner_programs.tests.test_application_team_models.ApplicationTeamDatabaseConstraintTests \ partner_programs.tests.test_application_team_service.ApplicationTeamServiceSequentialConflictTests \ partner_programs.tests.test_program_participation_policy.PartnerProgramParticipationPolicyConstraintTests \ + partner_programs.tests.test_team_api \ + partner_programs.tests.test_team_invite_api \ + partner_programs.tests.test_team_invite_service \ + partner_programs.tests.test_team_management_service \ --verbosity 2 - name: Run full backend test suite diff --git a/docs/backend-postgres-ci.md b/docs/backend-postgres-ci.md index 4db97e55..257ec636 100644 --- a/docs/backend-postgres-ci.md +++ b/docs/backend-postgres-ci.md @@ -34,6 +34,12 @@ Job поднимает одноразовый PostgreSQL 15 и последов Любая ошибка останавливает job. Ослабляющие флаги, пропуск ошибок и fake-миграции не используются. +Первый полный прогон на PostgreSQL выявил попытку блокировать nullable-сторону +outer join в team/invite-сервисах. Lock-query сохраняют `select_related` для +загрузки графа, но используют `select_for_update(of=("self",))`, чтобы +блокировать только строку основной модели. Порядок ключей JSONB не считается +контрактом: тесты проверяют состав и значения JSON-полей независимо от порядка. + ## Изоляция `procollab.settings_ci` подключается только через переменную diff --git a/mailing/tests/test_models_rendering.py b/mailing/tests/test_models_rendering.py index f0f19dfe..0ed2de23 100644 --- a/mailing/tests/test_models_rendering.py +++ b/mailing/tests/test_models_rendering.py @@ -75,10 +75,9 @@ def test_template_render_context_contains_schema_users_and_fields(self): self.assertTrue(context["picked_users"][0]["picked"]) self.assertEqual(context["unpicked_users"][0]["id"], unpicked_user.id) self.assertFalse(context["unpicked_users"][0]["picked"]) - self.assertEqual( - context["template_fields"], - [ - {"key": "title", "title": "Title", "default": "Default title"}, - {"key": "text", "title": "Text", "default": ""}, - ], - ) + template_fields = {field["key"]: field for field in context["template_fields"]} + self.assertEqual(set(template_fields), {"title", "text"}) + self.assertEqual(template_fields["title"]["title"], "Title") + self.assertEqual(template_fields["title"]["default"], "Default title") + self.assertEqual(template_fields["text"]["title"], "Text") + self.assertEqual(template_fields["text"]["default"], "") diff --git a/partner_programs/services/team_invites.py b/partner_programs/services/team_invites.py index 1835c525..08895a7c 100644 --- a/partner_programs/services/team_invites.py +++ b/partner_programs/services/team_invites.py @@ -66,9 +66,7 @@ class TeamInviteCapacityReachedError(ApplicationTeamServiceError): class TeamInviteActiveApplicationConflictError(ApplicationTeamServiceError): code = "team_invite_active_application_conflict" - default_detail = ( - "Пользователь уже участвует в другой активной заявке этой программы." - ) + default_detail = "Пользователь уже участвует в другой активной заявке этой программы." default_field = "user_id" @@ -93,12 +91,12 @@ def _lock_team_graph(team: Team) -> tuple[PartnerProgram, Application, Team]: program = PartnerProgram.objects.select_for_update().get(pk=program_id) application = ( - Application.objects.select_for_update() + Application.objects.select_for_update(of=("self",)) .select_related("program", "user", "created_by", "project") .get(pk=application_id) ) locked_team = ( - Team.objects.select_for_update() + Team.objects.select_for_update(of=("self",)) .select_related("application", "application__program", "captain") .get(pk=team.pk) ) @@ -111,7 +109,7 @@ def _lock_invite_graph( """Блокирует граф приглашения в том же порядке, что и операции Team.""" program, application, team = _lock_team_graph(invite.team) locked_invite = ( - TeamInvite.objects.select_for_update() + TeamInvite.objects.select_for_update(of=("self",)) .select_related("team", "team__application", "user", "invited_by") .get(pk=invite.pk) ) @@ -148,17 +146,25 @@ def _require_no_target_conflict(*, program: PartnerProgram, user: User) -> None: def _accepted_members_count(team: Team) -> int: - return TeamMember.objects.select_for_update().filter( - team=team, - status=TeamMember.STATUS_ACCEPTED, - ).count() + return ( + TeamMember.objects.select_for_update() + .filter( + team=team, + status=TeamMember.STATUS_ACCEPTED, + ) + .count() + ) def _pending_invites_count(team: Team) -> int: - return TeamInvite.objects.select_for_update().filter( - team=team, - status=TeamInvite.STATUS_PENDING, - ).count() + return ( + TeamInvite.objects.select_for_update() + .filter( + team=team, + status=TeamInvite.STATUS_PENDING, + ) + .count() + ) def _require_invite_capacity(*, team: Team, program: PartnerProgram) -> None: @@ -315,19 +321,25 @@ def create_team_invite( if ( application.participation_mode != Application.PARTICIPATION_MODE_TEAM or target.pk == team.captain_id - or TeamMember.objects.select_for_update().filter( + or TeamMember.objects.select_for_update() + .filter( team=team, user=target, status=TeamMember.STATUS_ACCEPTED, - ).exists() + ) + .exists() ): raise TeamInviteTargetInvalidError() - existing = TeamInvite.objects.select_for_update().filter( - team=team, - user=target, - status=TeamInvite.STATUS_PENDING, - ).first() + existing = ( + TeamInvite.objects.select_for_update() + .filter( + team=team, + user=target, + status=TeamInvite.STATUS_PENDING, + ) + .first() + ) if existing is not None: return TeamInviteCreationResult(existing, created=False) @@ -375,10 +387,14 @@ def accept_team_invite(*, invite: TeamInvite, actor: User) -> TeamInvite: _require_registered_target(program=program, user=invite.user) _require_no_target_conflict(program=program, user=invite.user) - member = TeamMember.objects.select_for_update().filter( - team=team, - user=invite.user, - ).first() + member = ( + TeamMember.objects.select_for_update() + .filter( + team=team, + user=invite.user, + ) + .first() + ) if member is not None and member.status == TeamMember.STATUS_ACCEPTED: raise TeamInviteTargetInvalidError( "Пользователь уже является участником этой команды." @@ -406,11 +422,15 @@ def accept_team_invite(*, invite: TeamInvite, actor: User) -> TeamInvite: # Принятие места в одной команде закрывает конкурирующие pending-инвайты # этого пользователя в рамках той же Program, но сохраняет их историю. - other_invites = TeamInvite.objects.select_for_update().filter( - user=invite.user, - status=TeamInvite.STATUS_PENDING, - team__application__program=program, - ).exclude(pk=invite.pk) + other_invites = ( + TeamInvite.objects.select_for_update() + .filter( + user=invite.user, + status=TeamInvite.STATUS_PENDING, + team__application__program=program, + ) + .exclude(pk=invite.pk) + ) list(other_invites.values_list("pk", flat=True)) other_invites.update( status=TeamInvite.STATUS_REVOKED, diff --git a/partner_programs/services/team_management.py b/partner_programs/services/team_management.py index 73390182..cd73b7e0 100644 --- a/partner_programs/services/team_management.py +++ b/partner_programs/services/team_management.py @@ -66,12 +66,12 @@ def _lock_team_graph(team: Team) -> tuple[PartnerProgram, Application, Team]: program = PartnerProgram.objects.select_for_update().get(pk=program_id) application = ( - Application.objects.select_for_update() + Application.objects.select_for_update(of=("self",)) .select_related("program", "user", "created_by", "project") .get(pk=application_id) ) locked_team = ( - Team.objects.select_for_update() + Team.objects.select_for_update(of=("self",)) .select_related("application", "application__program", "captain") .get(pk=team_id) ) @@ -121,9 +121,7 @@ def leave_team(*, team: Team, actor: User) -> TeamMember: raise CaptainTransferRequiredError() member = ( - TeamMember.objects.select_for_update() - .filter(team=team, user=actor) - .first() + TeamMember.objects.select_for_update().filter(team=team, user=actor).first() ) if ( member is None @@ -151,16 +149,11 @@ def remove_team_member(*, team: Team, actor: User, member_id: int) -> TeamMember _require_mutable_team(application=application, program=program) member = ( - TeamMember.objects.select_for_update() - .filter(team=team, pk=member_id) - .first() + TeamMember.objects.select_for_update().filter(team=team, pk=member_id).first() ) if member is None: raise TeamMemberNotFoundError() - if ( - member.role == TeamMember.ROLE_CAPTAIN - or member.user_id == team.captain_id - ): + if member.role == TeamMember.ROLE_CAPTAIN or member.user_id == team.captain_id: raise CaptainTransferRequiredError( "Капитана нельзя удалить из команды без передачи капитанства." ) From 3df062adc4df3588f9bda4da48d479008b97854e Mon Sep 17 00:00:00 2001 From: Fedor Date: Thu, 30 Jul 2026 23:42:59 +0300 Subject: [PATCH 3/3] make ci database cleanup deterministic --- .github/workflows/backend-postgres-ci.yml | 42 ++++++++++++++++++++++- docs/backend-postgres-ci.md | 8 +++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/.github/workflows/backend-postgres-ci.yml b/.github/workflows/backend-postgres-ci.yml index 32719c3c..eced28d2 100644 --- a/.github/workflows/backend-postgres-ci.yml +++ b/.github/workflows/backend-postgres-ci.yml @@ -118,4 +118,44 @@ jobs: --verbosity 2 - name: Run full backend test suite - run: poetry run python manage.py test --verbosity 1 + run: poetry run python manage.py test --verbosity 1 --keepdb + + - name: Remove retained PostgreSQL test database + if: always() + run: | + poetry run python - <<'PY' + import os + + import psycopg2 + from psycopg2 import sql + + test_database = "test_procollab_ci" + + connection = psycopg2.connect( + dbname=os.environ["DATABASE_NAME"], + user=os.environ["DATABASE_USER"], + password=os.environ["DATABASE_PASSWORD"], + host=os.environ["DATABASE_HOST"], + port=os.environ["DATABASE_PORT"], + ) + connection.autocommit = True + + try: + with connection.cursor() as cursor: + cursor.execute( + """ + SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = %s + AND pid <> pg_backend_pid() + """, + [test_database], + ) + cursor.execute( + sql.SQL("DROP DATABASE IF EXISTS {}").format( + sql.Identifier(test_database) + ) + ) + finally: + connection.close() + PY diff --git a/docs/backend-postgres-ci.md b/docs/backend-postgres-ci.md index 257ec636..26ce36bf 100644 --- a/docs/backend-postgres-ci.md +++ b/docs/backend-postgres-ci.md @@ -34,6 +34,14 @@ Job поднимает одноразовый PostgreSQL 15 и последов Любая ошибка останавливает job. Ослабляющие флаги, пропуск ошибок и fake-миграции не используются. +Полный suite запускается с `--keepdb`, чтобы завершившиеся WebSocket- и +асинхронные тесты не мешали Django вернуть результат из-за удаления базы внутри +того же Python-процесса. Это не переиспользование базы между job: каждый job +получает новый PostgreSQL container, а тестовая база создается заново. После +остановки тестового процесса обязательный шаг с `if: always()` завершает +оставшиеся подключения и явно удаляет `test_procollab_ci`. Ошибки тестов при +этом не скрываются и сохраняют job красным. + Первый полный прогон на PostgreSQL выявил попытку блокировать nullable-сторону outer join в team/invite-сервисах. Lock-query сохраняют `select_related` для загрузки графа, но используют `select_for_update(of=("self",))`, чтобы