Перейти до вмісту

Інцидент 2026-07-16 — прод-бекенд у краш-лупі: dev↔prod розбіжність схеми (колонка admissions_committee_website)

Abitly API (abitly-api-v2, NestJS + TypeORM 0.3.20) — ECS Fargate: кластер/сервіс abitly-prod-backend/abitly-prod-backend (eu-central-1, акаунт 952854879948), desired=1, за ALB abitly-prod-backend. Деплой — CodePipeline abitly-prod-backend: merge у main → авто build+deploy, immutable-образ у ECR за git-SHA.

Ключова деталь схеми: read-model University — це матеріалізований view universities, а не таблиця.

// src/database/entities/university.ts:11-18
@ViewEntity({
name: 'universities',
// Read model backed by the `universities` MATERIALIZED VIEW.
expression: ` CREATE MATERIALIZED VIEW "universities" AS SELECT s.*, ... FROM universities_static s ... `,
})
export class University { ... }

MV побудований як SELECT s.* над базовою таблицею universities_static. Список колонок MV фіксується в момент CREATE — додавання колонки в universities_static НЕ з’являється у MV автоматично; MV треба DROP + CREATE заново, щоб s.* підхопив нову колонку.

Як запускаються міграції на проді — ніяк автоматично:

МеханізмСтанДоказ
На старті застосункуdata-source.ts не має migrationsRun, synchronize: false
В entrypoint контейнераentrypoint.sh лише резолвить SSM-env і exec node dist/main.js
У CodePipeline/buildspecbuildspec.yml не має жодного migration-кроку

Отже прод-міграції запускають вручну (npm run migration:run проти прод-БД). Це відомий клас ризику (пор. сусідній інцидент того ж дня — симуляція без коефіцієнтів, де фікс-міграцію якраз накатили руками).

Хронологія (за Києвом, UTC+3)

Section titled “Хронологія (за Києвом, UTC+3)”
ЧасПодіяСтан
16.07 23:13–23:19Деплой PR #600 (:48, образ 2bde24af) — «НМТ-180 free simulation». Прод здоровий ~4 год🟢 baseline
17.07 00:34:10PR #601 dev→main змержено (merge 1941144f)🟢→🟡
00:34:15–00:42:01CodePipeline abitly-prod-backend (exec 997fd221…): build+deploy :49. Міграцій у pipeline немає🟡 бомба закладена
00:36:53ECS реєструє :49 PRIMARY, стартує перша :49-таска🟡
00:42:50Перший RunningTaskCount-low ALARM:49-таска впала (необроблений QueryFailedError) → ECS ставить заміну🔴 incident start
00:42–01:05Краш-луп: RunningTaskCount флапає ALARM↔OK кожні 1–3 хв (ECS-події: повторні start→drain→stop), heartbeat теж; /health у down-вікнах = 503; усі universities-join ендпоінти = 500🔴
~01:0xВиявлено: CloudWatch-алярми в Telegram (скриншот) → ескалація🔴 (MTTD≈0)
01:07Діагноз: aws logs tailcolumn Applicant__Applicant_selectedUniversities.admissions_committee_website does not exist; звірка :48:49 = ViewColumn нова в #601, міграцію не накочено🟡 root cause
01:08:30Відкат: update-service --task-definition abitly-prod-backend:48 --force-new-deployment — стартує :48-таска c752…🟡
01:09:02:48-таргет healthy; 01:09:52 :49 дренажиться🟡
01:10:51Сервіс has reached a steady state на :48🟢
01:11+/health 200 (6/6), /offers+/universities 200, алярми solid OK, 0 помилок🟢 mitigated

Вікно впливу: ~29 хв інтермітентного повного даунтайму (краш-луп кладе процес цілком, не лише вражені ендпоінти). MTTD ≈ 0 (алярм спрацював на онсеті). MTTR (онсет→мітигейт) ≈ 29 хв; від діагнозу до відкату ~3–4 хв. Статус: мітиговано, не resolved — #601 на проді не живий.

1. Нове читання MV-колонки, якої на проді немає

Section titled “1. Нове читання MV-колонки, якої на проді немає”

git diff між здоровим :48 і зламаним :49 показує рівно один доданий рядок у read-model:

# src/database/entities/university.ts (2bde24af → 1941144f)
@@ -101,6 +101,9 @@ export class University {
+ @ViewColumn({ name: 'admissions_committee_website' }) admissionsCommitteeWebsite: string;

TypeORM для будь-якого join-у до University авто-додає всі його @ViewColumn у SELECT. Тому після #601 запит getUserProfile, getOfferApplicants, факультети, /offers, а також boot-час search-params reindex — усі тягнуть admissions_committee_website із view universities. На проді view цієї колонки не має → Postgres кидає:

QueryFailedError: column Applicant__Applicant_selectedUniversities.admissions_committee_website does not exist
code: '42703' routine: 'errorMissingColumn'

Це прямий доказ стану прод-БД: саме прод-Postgres каже, що колонки у view немає. Дзеркальний доказ здоров’я :48: після відкату /universities і /offers (обидва join-лять MV) → 200.

2. Чому колонки немає у view, хоча вона «baseline»

Section titled “2. Чому колонки немає у view, хоча вона «baseline»”

admissions_committee_website існує на базовій таблиці universities_static давно (додана ще 1742231481871-UniversityInfo / 1778500002000-Rename…Static). Автор #601 у коментарі міграції так і пише — «pre-existing baseline column, intentionally NOT touched». Пастка в тому, що MV — це знімок схеми: доки universities не перестворять із SELECT s.* після появи колонки, view її не віддає. :48 не читав цю колонку — тому й не падав, попри той самий стан MV.

Рекреацію MV робить саме міграція #601:

// 1785000000000-AddFacultyStatusUniversityFeaturesOfferForming.ts (up)
await queryRunner.query('ALTER TABLE "universities_static" ADD COLUMN "custom_features" ...');
await queryRunner.query('DROP MATERIALIZED VIEW IF EXISTS "universities"');
await queryRunner.query(UNIVERSITIES_MV); // CREATE ... AS SELECT s.* → підхоплює admissions_committee_website
await queryRunner.query('CREATE UNIQUE INDEX "universities_mv_id_idx" ON "universities"("id")');
// + faculties.status enum (з is_closed), + offers.is_forming

Оскільки прод-міграції ручні (див. «Контекст») і після merge #601 їх ніхто не запустив — DROP+CREATE не відбувся → view лишився без колонки → код :49 несумісний зі схемою прода.

3. Чому це краш-луп, а не просто 500

Section titled “3. Чому це краш-луп, а не просто 500”

Помилка приходить не лише в request-хендлерах (де Nest-фільтр повернув би 500), а й у boot-час cron-і SearchParamsSyncCron (bootstrap reindex читає universities MV). Його падіння — необроблений promise-reject поза request-контекстом:

[SearchParamsSyncCron] search-params reindex (bootstrap) failed
QueryFailedError: column Offer__Offer_university.admissions_committee_website does not exist
...
Node.js v24.18.0 ← процес завершився з uncaught-помилкою

Node.js vXX.XX наприкінці — підпис виходу процесу через необроблену помилку. Далі: ECS бачить мертву таску (desired=1, running=0) → ставить нову → та завантажується → cron знову падає → цикл. Тому в подіях ECS видно повторні has started 1 taskshas begun draininghas stopped 1 running tasks, а RunningTaskCount флапає.

4. Супутнє: колізія timestamp міграцій

Section titled “4. Супутнє: колізія timestamp міграцій”

У цьому релізі два файли з однаковим numeric-префіксом 1785000000000:

1785000000000-AddFacultyStatusUniversityFeaturesOfferForming.ts (#601, MV-рекреація)
1785000000000-FixMedicalPsychologyCoeffsAndGk.ts (#598, вже накочено на прод)

(є ще друга колізія — 1784700000000 ×2.) TypeORM 0.3.20 визначає pending-міграції за іменем, тож обидві однаково застосуються — колізія не є причиною пропуску тут. Але це реальна латентна загроза: недетермінований порядок між однаковими timestamp-ами і оманливий migration:show. Її треба усунути окремо.

Чому це не зловили раніше

Section titled “Чому це не зловили раніше”
ГарантіяЧи булаЧому не спрацювала
Авто-запуск міграцій при деплої (boot/pipeline)❌ нікод і схема викочуються окремо; прод-міграції ручні — після merge #601 ніхто не запустив
CI-гейт check:migrations🟡 є, але вузькийперевіряє лише що DML-міграція має assert-guard; НЕ перевіряє ні застосування на проді, ні відповідність @ViewColumn↔MV, ні дублікати timestamp
Dev/staging, що ловить дрейф код↔схема🟡 частковона dev міграцію накотили (dev працює) → дефект не відтворюється поза продом; прод дрейфує окремо
Smoke-тест «кожен @ViewEntity селектиться з реального MV»❌ нінема інтеграційного тесту read-model проти MV після міграцій
ECS deployment circuit breaker + авто-rollback❌ вимкнено:49 встигав пройти healthcheck у вікні до падіння cron-а → rollout: COMPLETED; сигналу/відкату не дав
Заборона дублікатів timestamp❌ нідва 1785000000000 (+ два 1784700000000) пройшли рев’ю
Алерт на краш-луп (RunningTaskCount-low + heartbeat)спрацювавCloudWatch→Telegram підняв тривогу на онсеті — це й був канал виявлення (MTTD≈0)

Що реально відновило сервіс — відкат образу (дефект у деплойнутому коді, а не в даних, тож :48 — швидший і безпечніший шлях, ніж чекати міграцію):

Terminal window
export AWS_PROFILE=abitly AWS_REGION=eu-central-1
aws ecs update-service --cluster abitly-prod-backend --service abitly-prod-backend \
--task-definition abitly-prod-backend:48 --force-new-deployment
aws ecs wait services-stable --cluster abitly-prod-backend --services abitly-prod-backend

Фінальна перевірка (мітигейт):

ПеревіркаРезультат
curl api.abitly.org/health ×6200 (6/6)
curl api.abitly.org/offers?limit=1200 (join-ить universities MV)
curl api.abitly.org/universities?limit=1200
ECS running/desired на :481/1, target healthy, 0 restarts
Алярми RunningTaskCount-low, heartbeatOK, без флапів
Помилки admissions_committee_website у логах0 (після 01:09:47)

Це лише мітигейт. Повне resolved — roll-forward (див. перший пункт превентивів). ⚠️ CodePipeline авто-деплоїть на merge у main: наступний merge (або re-run) знову викотить :49 без міграції й поверне краш-луп — доки міграцію не накотять.

  • Roll-forward (закрити інцидент): накотити pending-міграції на прод — npm run migration:run з env із SSM /abitly/prod/backend/* (DB_HOST/DB_PORT/DB_USERNAME/DB_PASSWORD/DB_NAME/DB_SCHEMA=abitly, SSL rejectUnauthorized:false); спершу migration:show (звірити, скільки прод відстав). Потім повернути :49 (re-run pipeline або update-service на :49) і верифікувати /universities+/offers+профіль = 200. Тоді фічі #601 стануть живі.
  • Атомарний деплой код+схема: додати migration:run окремою стадією pipeline перед ECS-деплоєм (або one-off ECS-task із тим самим образом). Вбиває весь клас «code↔DB drift» — корінь і цього, і сусідніх інцидентів.
  • CI-lint на дублікати timestamp: розширити scripts/check-data-migrations.mjs — fail, якщо два файли мають однаковий numeric-префікс. Зараз колізій дві (1785000000000, 1784700000000).
  • Smoke read-model↔MV: інтеграційний тест, що після міграцій SELECT-ить кожен @ViewEntity проти реального MV — ловить забуту рекреацію view до релізу.
  • ECS deployment circuit breaker + rollback на abitly-prod-backend (ранній сигнал / авто-відкат нестабільного деплою).
  • Fault-tolerant bootstrap: SearchParamsSyncCron (bootstrap reindex) не має валити процес — обгорнути так, щоб помилка reindex не ставала uncaught-reject-ом (інакше будь-яка тимчасова помилка запиту = краш-луп).
  • Release-чекліст dev→prod: пункт «є нові міграції у діапазоні? → накотити на прод + migration:show перед/під час деплою».
  1. Скільки прод-міграцій зараз pending (наскільки прод відстав від main) — з’ясувати через migration:show проти прода перед roll-forward.
  2. Чи є в #601 ще read-model колонки поверх MV (напр. custom_features), які так само відваляться, якщо MV-рекреація на проді пройде неповно — перевірити після накату.
  3. Точний фатальний throw: boot-cron SearchParamsSyncCron vs request-path — обидва кидають; краш-луп указує на cron. Підтвердити при полагодженні fault-tolerance.

Ключові ідентифікатори

Section titled “Ключові ідентифікатори”
ПолеЗначення
Репо / релізabitly-org/abitly-api-v2 · PR #601 (merge 1941144f, змержено 2026-07-16T21:34:10Z)
Попередній «здоровий»PR #600 (образ 2bde24af) = task-def :48
Не накочена міграція1785000000000-AddFacultyStatusUniversityFeaturesOfferForming (DROP+CREATE MV universities; +faculties.status; +offers.is_forming; +universities_static.custom_features)
Дублікати timestamp1785000000000 (×2: …FixMedicalPsychologyCoeffsAndGk [#598, накочено] + …AddFacultyStatus…) · 1784700000000 (×2)
Сутність / колонкаsrc/database/entities/university.ts@ViewEntity('universities') (MV), нова @ViewColumn admissions_committee_website (рядок 104)
ECSкластер/сервіс abitly-prod-backend (eu-central-1, 952854879948); task-def :49 (зламано) → :48 (відкат)
PipelineCodePipeline abitly-prod-backend, exec 997fd221-6892-4500-89f7-962d0cf23b5a
Алярмиabitly-prod-api-RunningTaskCount-low, abitly-prod-heartbeat-abitly-api
БДschema abitly; MV universities над базовою universities_static; env DB_* зі SSM /abitly/prod/backend/*
  1. Код і схема мають викочуватись атомарно. Ручні прод-міграції — відкладена бомба: merge зелений, pipeline зелений, а БД відстала; будь-який запит по новій колонці кладе процес. Той самий корінь, що у сусідніх «прод-код нормалізований / прод-БД ні» інцидентів.
  2. Materialized view — це кеш схеми, а не таблиця. Додав колонку в базову таблицю ≠ вона є в SELECT s.* MV. Забута DROP+CREATE рекреація = рівно column does not exist. Кожен новий @ViewColumn вимагає застосованої MV-міграції.
  3. rollout: COMPLETED + running: 1 ≠ здоровий сервіс. Дивись на підпис краш-лупа (флап RunningTaskCount, повторні start/stop у ECS-подіях, /health у динаміці, а не миттєвий знімок) — інакше зловиш «up»-вікно й зробиш хибний висновок.
  4. Відкат образу лікує швидше за roll-forward, коли дефект у коді, а не в даних — але це мітигейт: фічі релізу лишаються не живими, доки не усунуто справжній корінь (тут — не накочена міграція).
  5. Дублікати timestamp міграцій — тиха загроза. TypeORM матчить pending за іменем (тож обидві застосуються), але порядок недетермінований, а migration:show вводить в оману. Один numeric-префікс = один слот у голові людини.
  6. Алерт, що спрацював, — герой цього інциденту. CloudWatch→Telegram дав MTTD≈0. Контраст із frontend-503 (70 хв виявлення людиною) — найкращий аргумент тримати такі алярми на кожному prod-сервісі.

Пов’язана документація

Section titled “Пов’язана документація”