fix(ci): прод-деплои в одну группу concurrency — прун одного убивал pull другого (#2950) (#2952)
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy Trade-In / changes (push) Successful in 10s
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-worker (push) Successful in 51s
Deploy / build-backend (push) Successful in 52s
Deploy / build-frontend (push) Successful in 51s
Deploy Trade-In / build-browser (push) Successful in 32s
Deploy / deploy (push) Successful in 1m21s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Successful in 3m30s
Deploy Trade-In / test (push) Successful in 4m15s
Deploy Trade-In / build-backend (push) Successful in 31s
Deploy Trade-In / deploy (push) Successful in 2m6s
Deploy Trade-In / deploy-status (push) Successful in 2s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s

This commit is contained in:
bot-backend 2026-08-20 07:47:48 +00:00
parent c93d6cbde1
commit 2a01dea103
5 changed files with 102 additions and 1 deletions

View file

@ -158,6 +158,15 @@ jobs:
# переведён в expired, test_get_role_known_users стал красным и # переведён в expired, test_get_role_known_users стал красным и
# доехал до main незамеченным (починен в PR #2587). # доехал до main незамеченным (починен в PR #2587).
- 'auth/**' - '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' - '.forgejo/workflows/ci.yml'
frontend: frontend:
- 'frontend/**' - 'frontend/**'

View file

@ -11,8 +11,23 @@ on:
- ".forgejo/workflows/deploy-tradein.yml" - ".forgejo/workflows/deploy-tradein.yml"
workflow_dispatch: 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: concurrency:
group: deploy-tradein-prod group: deploy-prod
cancel-in-progress: false cancel-in-progress: false
env: env:

View file

@ -35,6 +35,21 @@ on:
- "ops/docker-prune.sh" - "ops/docker-prune.sh"
workflow_dispatch: 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: concurrency:
group: deploy-prod group: deploy-prod
cancel-in-progress: false cancel-in-progress: false

View file

View file

@ -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 — иначе более новый мерж "
"отменит выкатку предыдущего и та молча не доедет"
)