gendesign/backend/tests/ops/test_2950_deploy_concurrency_group.py
bot-backend c930b12d5f
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 9s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 1m13s
CI / openapi-codegen-check (pull_request) Successful in 2m5s
CI / backend-tests (pull_request) Successful in 17m19s
fix(ci): прод-деплои сведены в одну группу concurrency — прун одного убивал pull другого (#2950)
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 <noreply@anthropic.com>
2026-08-20 12:25:58 +05:00

62 lines
3.5 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Гейт: оба прод-деплоя обязаны лежать в ОДНОЙ группе 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 — иначе более новый мерж "
"отменит выкатку предыдущего и та молча не доедет"
)