Інцидент 2026-07-16 — прод-бекенд у краш-лупі: dev↔prod розбіжність схеми (колонка admissions_committee_website)
Контекст
Section titled “Контекст”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/buildspec | ❌ | buildspec.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:10 | PR #601 dev→main змержено (merge 1941144f) | 🟢→🟡 |
| 00:34:15–00:42:01 | CodePipeline abitly-prod-backend (exec 997fd221…): build+deploy :49. Міграцій у pipeline немає | 🟡 бомба закладена |
| 00:36:53 | ECS реєструє :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 tail → column 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 на проді не живий.
Першопричина (deep-dive)
Section titled “Першопричина (deep-dive)”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_websiteawait 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) failedQueryFailedError: 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 tasks → has begun draining → has 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) |
Виправлення
Section titled “Виправлення”Що реально відновило сервіс — відкат образу (дефект у деплойнутому коді, а не в даних, тож :48 — швидший і безпечніший шлях, ніж чекати міграцію):
export AWS_PROFILE=abitly AWS_REGION=eu-central-1aws ecs update-service --cluster abitly-prod-backend --service abitly-prod-backend \ --task-definition abitly-prod-backend:48 --force-new-deploymentaws ecs wait services-stable --cluster abitly-prod-backend --services abitly-prod-backendФінальна перевірка (мітигейт):
| Перевірка | Результат |
|---|---|
curl api.abitly.org/health ×6 | 200 (6/6) |
curl api.abitly.org/offers?limit=1 | 200 (join-ить universities MV) |
curl api.abitly.org/universities?limit=1 | 200 |
ECS running/desired на :48 | 1/1, target healthy, 0 restarts |
Алярми RunningTaskCount-low, heartbeat | OK, без флапів |
Помилки admissions_committee_website у логах | 0 (після 01:09:47) |
Це лише мітигейт. Повне resolved — roll-forward (див. перший пункт превентивів). ⚠️ CodePipeline авто-деплоїть на merge у main: наступний merge (або re-run) знову викотить :49 без міграції й поверне краш-луп — доки міграцію не накотять.
Превентивні заходи
Section titled “Превентивні заходи”- 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, SSLrejectUnauthorized: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перед/під час деплою».
Відкриті питання
Section titled “Відкриті питання”- Скільки прод-міграцій зараз pending (наскільки прод відстав від
main) — з’ясувати черезmigration:showпроти прода перед roll-forward. - Чи є в #601 ще read-model колонки поверх MV (напр.
custom_features), які так само відваляться, якщо MV-рекреація на проді пройде неповно — перевірити після накату. - Точний фатальний throw: boot-cron
SearchParamsSyncCronvs 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) |
| Дублікати timestamp | 1785000000000 (×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 (відкат) |
| Pipeline | CodePipeline 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/* |
- Код і схема мають викочуватись атомарно. Ручні прод-міграції — відкладена бомба: merge зелений, pipeline зелений, а БД відстала; будь-який запит по новій колонці кладе процес. Той самий корінь, що у сусідніх «прод-код нормалізований / прод-БД ні» інцидентів.
- Materialized view — це кеш схеми, а не таблиця. Додав колонку в базову таблицю ≠ вона є в
SELECT s.*MV. ЗабутаDROP+CREATEрекреація = рівноcolumn does not exist. Кожен новий@ViewColumnвимагає застосованої MV-міграції. rollout: COMPLETED+running: 1≠ здоровий сервіс. Дивись на підпис краш-лупа (флапRunningTaskCount, повторні start/stop у ECS-подіях,/healthу динаміці, а не миттєвий знімок) — інакше зловиш «up»-вікно й зробиш хибний висновок.- Відкат образу лікує швидше за roll-forward, коли дефект у коді, а не в даних — але це мітигейт: фічі релізу лишаються не живими, доки не усунуто справжній корінь (тут — не накочена міграція).
- Дублікати timestamp міграцій — тиха загроза. TypeORM матчить pending за іменем (тож обидві застосуються), але порядок недетермінований, а
migration:showвводить в оману. Один numeric-префікс = один слот у голові людини. - Алерт, що спрацював, — герой цього інциденту. CloudWatch→Telegram дав MTTD≈0. Контраст із frontend-503 (70 хв виявлення людиною) — найкращий аргумент тримати такі алярми на кожному prod-сервісі.
Пов’язана документація
Section titled “Пов’язана документація”- Abitly API service card — рантайм, env, деплой
- Deploy pipeline — CodePipeline
abitly-prod-backend(merge→auto-deploy, без міграцій) - Deploy rollback — швидкий відкат ECS на попередню task-def
- Service down — first-look triage 5xx
- DB issues — доступ до прод-БД, міграції
- Environments / SSM — простори параметрів
/abitly/prod/backend/* - Інцидент 2026-07-16 — симуляція без коефіцієнтів — той самий день; #598, чия міграція ділить timestamp
1785000000000з винуватцем цього інциденту - Інцидент 2026-06-12 — frontend 503 — споріднений клас: деплой-час краш через розсинхрон конфіг↔код, алерт-дірка