From c930b12d5f6010c2eb3cbd94ab7338fda2eb482c Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 20 Aug 2026 12:25:58 +0500 Subject: [PATCH] =?UTF-8?q?fix(ci):=20=D0=BF=D1=80=D0=BE=D0=B4-=D0=B4?= =?UTF-8?q?=D0=B5=D0=BF=D0=BB=D0=BE=D0=B8=20=D1=81=D0=B2=D0=B5=D0=B4=D0=B5?= =?UTF-8?q?=D0=BD=D1=8B=20=D0=B2=20=D0=BE=D0=B4=D0=BD=D1=83=20=D0=B3=D1=80?= =?UTF-8?q?=D1=83=D0=BF=D0=BF=D1=83=20concurrency=20=E2=80=94=20=D0=BF?= =?UTF-8?q?=D1=80=D1=83=D0=BD=20=D0=BE=D0=B4=D0=BD=D0=BE=D0=B3=D0=BE=20?= =?UTF-8?q?=D1=83=D0=B1=D0=B8=D0=B2=D0=B0=D0=BB=20pull=20=D0=B4=D1=80?= =?UTF-8?q?=D1=83=D0=B3=D0=BE=D0=B3=D0=BE=20(#2950)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 20.08 деплой ПТИЦЫ (run 8083) упал за 5 секунд: unable to lease content: lease does not exist: not found Все 7 образов ушли в Interrupted через 0.8с после старта пула. Причина — не в ПТИЦЕ: через 0.4с после обрыва соседний деплой Trade-In напечатал «Deleted Images:» своего `docker image prune -af`. Оба деплоя ходят по SSH в ОДИН докер-демон: стеки gendesign-* и tradein-* плюс сам forgejo-runner живут на одной VM (проверено `docker ps` на хосте). Группы были разные — deploy-prod и deploy-tradein-prod — поэтому Forgejo запускал их параллельно. `cancel-in-progress: false` не спасал: он про раны ВНУТРИ группы, а гонка была МЕЖДУ группами. Последствие было тихим: голова main показывала success, но зелёным был Trade-In'овый деплой этой головы, а деплой ПТИЦЫ висел на своём, более раннем коммите с исходом failure. Прод остался на старом коде — #2946 и #2947 не доехали и были выкачены отдельным workflow_dispatch. Почему общая группа, а не flock на хосте. Host-lock сериализовал бы только докер-секцию, не заставляя деплои ждать чужих билдов (~6 мин). Но у него своя отказная мода: дочерний процесс наследует fd лока, и при аварийной смерти job'а лок залипает. Проверено на самой VM: после kill -9 держателя следующий претендент лок НЕ получил. Залипший лок блокирует прод-деплои на весь таймаут. Планировщик Forgejo сериализует без лок-файла, залипать там нечему — платим ожиданием, покупаем отсутствие целого класса отказов. Гейт backend/tests/ops/test_2950_deploy_concurrency_group.py фиксирует инвариант. Мутационно проверен: разводка групп обратно, cancel-in-progress: true и удаление секции concurrency краснят его (1, 1 и 3 упавших теста), контроль зелёный. В фильтр changes добавлены сами deploy-workflow'ы — иначе правка, разводящая группы, не трогает 'backend/**', backend-tests пропускается, и гейт не исполняется ровно на той правке, от которой стережёт. Тот же класс, что #2587. Co-Authored-By: Claude Opus 5 --- .forgejo/workflows/ci.yml | 9 +++ .forgejo/workflows/deploy-tradein.yml | 17 ++++- .forgejo/workflows/deploy.yml | 15 +++++ backend/tests/ops/__init__.py | 0 .../ops/test_2950_deploy_concurrency_group.py | 62 +++++++++++++++++++ 5 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 backend/tests/ops/__init__.py create mode 100644 backend/tests/ops/test_2950_deploy_concurrency_group.py diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 89305795..84a6924c 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -158,6 +158,15 @@ jobs: # переведён в expired, test_get_role_known_users стал красным и # доехал до main незамеченным (починен в PR #2587). - 'auth/**' + # Тот же класс, что и с auth/** выше (#2950). В backend/tests/ops/ + # лежат гейты на сами workflow-файлы — например «оба прод-деплоя + # обязаны быть в одной группе concurrency». Правка, разводящая + # группы обратно, не трогает 'backend/**' → без этих строк + # backend-tests пропускался бы, гейт не исполнялся, и регрессия + # уезжала в main зелёной. Гейт, который не запускается на той самой + # правке, от которой стережёт, — украшение. + - '.forgejo/workflows/deploy.yml' + - '.forgejo/workflows/deploy-tradein.yml' - '.forgejo/workflows/ci.yml' frontend: - 'frontend/**' diff --git a/.forgejo/workflows/deploy-tradein.yml b/.forgejo/workflows/deploy-tradein.yml index 16274c2f..5720b1f3 100644 --- a/.forgejo/workflows/deploy-tradein.yml +++ b/.forgejo/workflows/deploy-tradein.yml @@ -11,8 +11,23 @@ on: - ".forgejo/workflows/deploy-tradein.yml" workflow_dispatch: +# #2950: ОБЩАЯ группа с deploy-tradein.yml — не опечатка и не копипаста. +# Оба деплоя ходят по SSH в ОДИН докер-демон (стеки gendesign-* и tradein-* +# плюс сам forgejo-runner живут на одной VM), и `docker image prune -af` одного +# сносит leases ещё не доехавшего `compose pull` другого: +# unable to lease content: lease does not exist: not found +# 20.08 так и вышло: run 8083 упал за 5с — прун соседнего деплоя отработал через +# 0.4с после обрыва пула. Прод остался на старом коде, при том что голова main +# показывала success (зелёным был чужой, Trade-In'овый деплой той же головы). +# Разные группы + cancel-in-progress: false не спасают: false сериализует раны +# ВНУТРИ группы, а гонка была МЕЖДУ группами. +# Цена: деплои ждут друг друга целиком, вместе с билдами (~6 мин). Осознанно: +# host-lock (flock) сериализовал бы только докер-секцию, но у него своя отказная +# мода — дочерний процесс наследует fd лока и при аварийной смерти job'а лок +# залипает (проверено на хосте: после kill -9 лок остался занят). Сериализацию +# гарантирует планировщик Forgejo, залипать там нечему. concurrency: - group: deploy-tradein-prod + group: deploy-prod cancel-in-progress: false env: diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index c463a0d2..64688aa3 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -35,6 +35,21 @@ on: - "ops/docker-prune.sh" workflow_dispatch: +# #2950: ОБЩАЯ группа с deploy-tradein.yml — не опечатка и не копипаста. +# Оба деплоя ходят по SSH в ОДИН докер-демон (стеки gendesign-* и tradein-* +# плюс сам forgejo-runner живут на одной VM), и `docker image prune -af` одного +# сносит leases ещё не доехавшего `compose pull` другого: +# unable to lease content: lease does not exist: not found +# 20.08 так и вышло: run 8083 упал за 5с — прун соседнего деплоя отработал через +# 0.4с после обрыва пула. Прод остался на старом коде, при том что голова main +# показывала success (зелёным был чужой, Trade-In'овый деплой той же головы). +# Разные группы + cancel-in-progress: false не спасают: false сериализует раны +# ВНУТРИ группы, а гонка была МЕЖДУ группами. +# Цена: деплои ждут друг друга целиком, вместе с билдами (~6 мин). Осознанно: +# host-lock (flock) сериализовал бы только докер-секцию, но у него своя отказная +# мода — дочерний процесс наследует fd лока и при аварийной смерти job'а лок +# залипает (проверено на хосте: после kill -9 лок остался занят). Сериализацию +# гарантирует планировщик Forgejo, залипать там нечему. concurrency: group: deploy-prod cancel-in-progress: false diff --git a/backend/tests/ops/__init__.py b/backend/tests/ops/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/tests/ops/test_2950_deploy_concurrency_group.py b/backend/tests/ops/test_2950_deploy_concurrency_group.py new file mode 100644 index 00000000..29ef0b01 --- /dev/null +++ b/backend/tests/ops/test_2950_deploy_concurrency_group.py @@ -0,0 +1,62 @@ +"""Гейт: оба прод-деплоя обязаны лежать в ОДНОЙ группе concurrency. + +Почему это тест, а не комментарий в yml (#2950). Деплой ПТИЦЫ и деплой Trade-In +ходят по SSH в один и тот же докер-демон — стеки gendesign-* и tradein-* плюс сам +forgejo-runner живут на одной VM. Каждый в конце делает `docker image prune -af`. +Пока группы разные, Forgejo запускает их параллельно, и прун одного сносит leases +ещё не доехавшего `compose pull` другого: + + unable to lease content: lease does not exist: not found + +20.08 так и вышло: run 8083 упал за 5 секунд, прод остался на старом коде. Разводка +групп обратно ничего не сломает в CI и не даст ни одного сигнала — отказ проявится +только в следующем совпадении окон двух мержей. Поэтому инвариант зафиксирован здесь. + +`cancel-in-progress: false` частью инварианта тоже является: прод-деплои обязаны +достаиваться в очереди, а не отменять друг друга — отменённый деплой оставляет прод +на старом коде ровно так же, как упавший. +""" + +from pathlib import Path + +import pytest +import yaml + +# backend/tests/ops/<этот файл> → корень репозитория +REPO_ROOT = Path(__file__).resolve().parents[3] +WORKFLOWS = REPO_ROOT / ".forgejo" / "workflows" + +PROD_DEPLOYS = ("deploy.yml", "deploy-tradein.yml") + + +def _concurrency(name: str) -> dict: + path = WORKFLOWS / name + assert path.is_file(), f"нет {path} — переехал workflow, гейт ослеп" + return yaml.safe_load(path.read_text()).get("concurrency") or {} + + +@pytest.mark.parametrize("name", PROD_DEPLOYS) +def test_prod_deploy_declares_concurrency(name: str) -> None: + """У каждого прод-деплоя concurrency вообще объявлена.""" + assert _concurrency(name).get( + "group" + ), f"{name}: пропала секция concurrency — деплои снова могут пойти параллельно" + + +def test_both_prod_deploys_share_one_group() -> None: + """Группа у обоих одна и та же — иначе прун одного убьёт pull другого.""" + groups = {name: _concurrency(name).get("group") for name in PROD_DEPLOYS} + assert len(set(groups.values())) == 1, ( + f"прод-деплои разведены по разным группам concurrency: {groups}. " + "Они ходят в ОДИН докер-демон; `docker image prune -af` одного обрывает " + "`compose pull` другого (#2950)." + ) + + +@pytest.mark.parametrize("name", PROD_DEPLOYS) +def test_prod_deploy_queues_instead_of_cancelling(name: str) -> None: + """Раны достаиваются в очереди: отменённый деплой = прод на старом коде.""" + assert _concurrency(name).get("cancel-in-progress") is False, ( + f"{name}: cancel-in-progress должен быть false — иначе более новый мерж " + "отменит выкатку предыдущего и та молча не доедет" + ) -- 2.45.3