Інцидент 2026-07-09/10 — каталог КП: «Показати результати (N)» і порожній список
Контекст
Section titled “Контекст”Каталог спеціальностей на abitly.org — головний пошук КП. Abitly API обслуговує його двома різними шляхами даних:
GET /offers(список карток) — на prod читає Typesense (SEARCH_ENGINEне заданий → дефолтtypesense): пошук по колекціїoffersповертає ids, потім Postgres догружає картки. Поверх — ручний Redis-кеш відповідей per-URL (OffersController-getOffers:GET:<url>:<query-json>…, TTLCACHE_TTL|| 86400 с) у Valkeyabitly-prod-cache.GET /offers/count(лічильник «Показати результати (N)») — завжди Postgres через query builder.
Фронтенд ускладнює діагностику: список тягне сервер Next.js (axios у RSC, page.tsx → qc.fetchQuery(offerQueries.all)), браузер запит /offers не робить узагалі — у DevTools видно тільки /offers/count. Клієнтські проби через браузер тому «брешуть».
Typesense — одна інстанція на dev і prod (TYPESENSE_HOST в обох env = typesense.abitly.internal, перевірено в SSM), колекції спільні. Цей самий факт уже спричинив інцидент 03–04.07 з автокомплітом спеціальностей (клас «dev clobber shared collection», фікси api#548/#552/#553 — але guard тоді поставили лише на search-params).
Хронологія (UTC+3)
Section titled “Хронологія (UTC+3)”| Час | Подія | Стан |
|---|---|---|
| ~21.07.2025 | Останній повний імпорт offers (table_metadata.lastUpdated = 2025-07-21T12:00Z); приблизно тоді ж востаннє збудовано Typesense-колекцію offers. Відтоді реіндекс не запускався жодного разу — виклик syncOffersToTypesense закоментований (offers.controller.ts:66) | 🟡 деградація починається |
| 2025-07…2026-07 | Кожна КП, додана/перепривʼязана після білда, невидима у списку каталогу (лічильник бачить); фільтр «рік 2024» у списку — 0 завжди | 🟡 непомічено ~11,5 міс |
| 09.07 ~16:07 | Користувач: «чому тут немає КП» на комбінації ЛНУ×C3. Діагностика: у БД 4 відкриті оффери; /offers/count=4; /offers={count:0,data:null} | 🔴 виявлено |
| 09.07 16:16–16:25 | Root cause шару 1 доведено: list=36 811 зі штампом 2025-07-21 vs count=23 679 (рік 2025); list=0 vs count=21 191 (рік 2024); заморожений індекс має spec-374 у 5 ЗВО, але не в ЛНУ | 🟡 діагноз |
| 10.07 18:59 | PR api#574 merged → main; CodePipeline abitly-prod-backend (exec 560600dc) стартує автоматично | 🟡 фікс їде |
| 19:03 | Нова таска (:38) бутиться; bootstrap-реіндекс будує offers_1783699377947_* і search-params_1783699373004_*, alias-swap | 🟢 індекс свіжий |
| 19:06 | Верифікація API: комбінація ЛНУ×C3 → 4 оффери; list==count по роках | 🟢 |
| 19:10:36 | Виклик зі старим кодом (сигнатура createOffersCollection, існує лише як dev POST /admin/offers/sync-typesense): видалено aliased-таргет прод-індексу, створено порожню справжню колекцію offers; alias лишився висіти на неіснуючій колекції | 🔴 clobber |
| 19:10–19:29 | GET /offers повз кеш → 0 по всіх роках; частину трафіку маскують закешовані відповіді (суміш до- і після-реіндексних) | 🔴 |
| 19:16:07 | dev-логи: http.log.error EOF (обрив клієнтського зʼєднання) → 19:16:14 рестарт Nest на dev. Імпорт так і не відбувся — тому колекція лишилась з 0 доків | 🔴 механізм зафіксовано |
| ~19:14 | (паралельно, ще до виявлення clobber) знайдено шар 3: Redis-кеш відповідей; перший флуш 4 864 ключів OffersController-getOffers:* | 🟡 |
| ~19:25 | Live-запити всі 0 → інтроспекція Typesense через bastion-тунель: alias offers → неіснуюча колекція; поруч порожня справжня offers (created 19:10:36) | 🟡 clobber доведено |
| 19:29:05 | POST /admin/offers/reindex-offers (прод, новий код): 44 870 КП за 13,3 с → alias-swap прибрав rogue-колекцію і перевів alias на свіжу | 🟢 індекс відновлено |
| ~19:35 | Другий флуш Redis (119 ключів, отруєних у вікні clobber). API: SSR-набір параметрів → 4; totals live 21 191 / 23 679 | 🟢 |
| 19:36–19:41 | SSR-HTML містить усі 4 картки; браузером видно всі 4 КП + ЛНУ. Resolved | 🟢 |
| 19:48 | Превентив: PR api#575 (guard на dev) merged → авто-деплой dev (exec 37badf0d Succeeded); 19:51:44 dev live з guard-ом | 🟢 клас закрито для offers |
Вікно впливу: шар 1 — ~11,5 місяців часткової невидимості нових КП (масштаб точно не вимірюваний: у офферів немає created_at; мінімум — приклад-комбо 4/4 невидимі, весь рік-2024 у списку = 0). Шар 2 — ~18,5 хв повністю порожнього списку. MTTD шару 1 ≈ 11,5 міс (виявив користувач); діагностика ≈ 20 хв; MTTR від merge до user-visible fix ≈ 42 хв (з них ~19 хв — боротьба з clobber-ом і кешем).
Першопричина (deep-dive)
Section titled “Першопричина (deep-dive)”Шар 1: list і count живуть у різних світах, і світ списку замерз
Section titled “Шар 1: list і count живуть у різних світах, і світ списку замерз”OffersService.getPaginatedOffersBySearchEngine (prod → getPaginatedOffersViaTypesense) шукає ids у колекції offers; getOffersCount рахує в Postgres. Розбіжність тиха: коли Typesense повертає 0 ids, метод чесно віддає {count:0, data:null} без жодної помилки.
Єдиний спосіб перебудувати колекцію — syncOffersToTypesense() — не мав жодного тригера:
// offers.controller.ts:66 (main до #574)// await this.offersService.syncOffersToTypesense();і був жорстко зашитий на роки In([2025, 2024]) (offers.service.ts:196). Після інциденту 03.07 автоматизацію (bootstrap + nightly cron + адмін-тригер) отримав тільки search-params — сусідня колекція offers лишилась сиротою.
Докази заморозки (09.07):
/offers?year=2025 → count 36 811, updateTime 2025-07-21T12:00Z | /offers/count?year=2025 → 23 679/offers?year=2024 → count 0, updateTime = поточний час | /offers/count?year=2024 → 21 191/offers?year=2026 → count 12 990 (фантомні) | /offers/count?year=2026 → 0Заморожений індекс мав spec-374 (C3) у ДПУ/УДУ Драгоманова/Ніжині/ОЮА/Каразіні — але не в ЛНУ: 4 оффери ЛНУ (1441192, 1441196, 1478920, 1492787 — усі «Відкрита», Денна) зʼявилися в БД уже після білда.
Пастка: updateTime у відповіді /offers — це table_metadata.offers.lastUpdated з Postgres (дата останнього імпорту даних), а не свіжість індексу. Він «підтверджує» дату заморозки випадково і збиває з пантелику.
Шар 2: спільний Typesense + незахищений dev-ендпоінт = clobber №3
Section titled “Шар 2: спільний Typesense + незахищений dev-ендпоінт = clobber №3”dev-гілка api-v2 має власний ендпоінт POST /admin/offers/sync-typesense (offers-admin.controller.ts:29 на dev) → refreshTypesense() → старий syncOffersToTypesense, який починає з:
// typesense.service.ts (старий createOffersCollection)await this.client.collections('offers').delete(); // ← alias резолвиться: видаляє ЖИВИЙ прод-таргетreturn await this.client.collections().create(schema); // ← створює порожню справжню колекціюЧерез alias delete() зніс offers_1783699377947_* (щойно збудований фіксом), alias завис на мертвому імені, а порожня справжня offers зайняла місце. Імпорт мав наповнити її пізніше — але dev вантажить усі роки (MIN_SUPPORTED_ADMISSION_YEAR..CURRENT, фіча NMT-2022-2023) з реляціями, і процес не дожив: о 19:16:07 у dev-логах EOF (обрив зʼєднання клієнта), о 19:16:14 — рестарт Nest. Результат: 0 доків назавжди.
Guard із PR #552 (SEARCH_ENGINE !== 'typesense' → skip) покривав лише syncSearchParamsToTypesense — на dev він чесно логує skip (видно в тому ж боот-лозі), а от offers-шлях guard-а не мав.
Доказ стану (інтроспекція через bastion-тунель):
GET /aliases → offers → offers_1783699377947_88319 (колекції з таким імʼям НЕМАЄ)GET /collections → offers: num_documents=0, created_at=1783699836 (=19:10:36+03)Шар 3: кеш відповідей, який переживає і реіндекс, і деплой
Section titled “Шар 3: кеш відповідей, який переживає і реіндекс, і деплой”getOffers кешує готові відповіді per-URL (offers.controller.ts:67, ключ = OffersController-getOffers:GET:<url>:<query-json>:<params-json>, TTL = CACHE_TTL || 86400 с) у зовнішньому Valkey — тож ані реіндекс, ані рестарт таски його не чіпають. Наслідки:
- SSR-запити фронтенду (
limit=15) місяцями клали в кеш порожні списки; після ремонту індексу сторінка все одно була порожня. - Мої ж верифікаційні запити (
limit=10) закешували «4» — і під час clobber-вікна показували «все добре», коли live вже було 0. Кеш маскує обидва напрямки.
Той самий клас, що й у кейсі зі зміною ціни симуляції (ключ *getSimulationProduct*, 12h TTL): «поміняв джерело даних → мусиш флушнути відповідний кеш».
Чому це не зловили раніше
Section titled “Чому це не зловили раніше”| Можлива гарантія | Чи була | Чому не спрацювала |
|---|---|---|
| Алерт на розбіжність list vs count | ❌ ні | Два шляхи даних ніде не звіряються; UI при цьому виглядає «живим» (кнопка з N) |
Автоматичний реіндекс offers | ❌ ні | Після 03.07 автоматизацію дали лише search-params; клас «сирітська колекція» не закрили для сусідів |
| Env-guard на записи в спільний Typesense | 🟡 частково | PR #552 захистив лише search-params; dev-ендпоінт sync-typesense пише в offers без guard-а |
| Per-env Typesense / namespaced колекції | ❌ ні | Відомий борг ще з 03.07, follow-up двічі відкладено — це вже третій інцидент класу |
| Інвалідація Redis-кешу при реіндексі | ❌ ні | Кеш відповідей ніяк не звʼязаний із життєвим циклом індексу |
| CI-гейт на PR | ❌ зламаний | GH Actions в api-v2 падає repo-wide за ~3 с (біллінг) — тести ганялись локально, merge адмін-ом |
| Свіжість індексу видно назовні | ❌ оманливо | updateTime у відповіді — метадані Postgres-імпорту, не індексу |
Виправлення (що реально спрацювало)
Section titled “Виправлення (що реально спрацювало)”Код (PR api#574 → main, задеплоєно 10.07 19:05):
- Zero-downtime alias-swap реіндекс для
offers— узагальнено наявний механізмsearch-paramsу спільнийreindexCollectionViaAlias(alias, schemaFactory, docs): збудуватиoffers_<ts>_<rand>збоку → імпорт → перевірка partial-import → атомарний alias upsert → прибрати сирітські колекції (race-safe при конкурентних ребілдах). Старий delete-then-recreatecreateOffersCollectionвидалено. - Env-guard:
SEARCH_ENGINE !== 'typesense'→ skip (дзеркало #552). - Роки
[CURRENT_ADMISSION_YEAR, CURRENT_ADMISSION_YEAR-1]замість зашитих[2025, 2024]. - Ребілд обох колекцій на bootstrap (деплой самолікується) і nightly 04:07 (
SearchParamsSyncCron, збої ізольовані per-collection). - Адмін-тригер
POST /admin/offers/reindex-offers(guardx-admin-key, ключ в SSM/abitly/prod/backend/ADMIN_API_KEY). - Null-safety doc-builder-а: на проді 4 університети мають
NULL name_en(ids 4760, 6803, 6917, 6743; 27 офферів 2024–2025) — без?.toLowerCase() || ''один такий рядок навічно валив би кожен ребілд, а fail-safe alias-swap мовчки лишав би старий індекс. - Тести: guard ×2 колекції, cron (обидва ребілди, ізоляція збоїв), null-tolerant
syncOffers. Локально 778/778 зелених,nest buildчистий.
Код (PR api#575 → dev, задеплоєно 10.07 19:51): той самий guard на dev-версію syncOffersToTypesense — sync-typesense на dev тепер чесно відповідає skipped і не пише в спільну колекцію. 1041/1041 тестів.
Ops (відновлення):
# 1. Реіндекс prod (новий код): 44 870 КП за 13,3 сcurl -X POST https://api.abitly.org/admin/offers/reindex-offers -H "x-admin-key: $ADMIN_API_KEY"# → {"offers":44870,"years":[2025,2024]}
# 2. Флуш отруєного кешу відповідей (тунель до Valkey через bastion i-06c688…, порт 6379)# ⚠️ ключі містять JSON із лапками — plain xargs мовчки видаляє НУЛЬ; тільки NUL-terminated:redis-cli -p 56379 --scan --pattern 'OffersController-getOffers:*' \ | tr '\n' '\0' | xargs -0 -n300 redis-cli -p 56379 UNLINK# (ключі getOffersCount НЕ чіпати — вони з Postgres і завжди коректні)Фінальна перевірка:
| Перевірка | Результат |
|---|---|
/offers vs /offers/count, 2024 | 21 191 == 21 191 (було 0 vs 21 191) |
/offers vs /offers/count, 2025 | 23 679 == 23 679 (було 36 811 vs 23 679) |
| Комбо ЛНУ×C3, SSR-набір параметрів | 4 оффери [1441196, 1492787, 1478920, 1441192] |
| SSR-HTML сторінки | усі 4 назви КП присутні |
| Браузер (Playwright) | 4 картки + ЛНУ видно |
| Автокомпліт (регресія) | F1 → id 303, «Львів» → 5 ЗВО + регіон — без змін |
Превентивні заходи
Section titled “Превентивні заходи”- 10.07: alias-swap + guard + динамічні роки + bootstrap/nightly cron + адмін-тригер + null-safety (api#574, prod LIVE).
- 10.07: env-guard на dev
syncOffersToTypesense(api#575, dev LIVE). - 10.07: індекс відновлено (44 870 КП), ~5 000 отруєних Redis-ключів вичищено, сторінка верифікована браузером.
- Per-env Typesense або namespace-префікси колекцій — три інциденти одного класу за тиждень (03.07, 04.07, 10.07); guard-и лікують симптом, спільна інстанція лишається міною. Заодно виправити databases: хост однаковий для dev/prod.
- Інвалідація
OffersController-getOffers:*після успішного реіндексу (хук уsyncOffersToTypesense/ адмін-тригері) — інакше «полагодив індекс» стає видимим користувачам лише за ≤24 год. - Синтетична звірка list vs count (той самий фільтр в обидва ендпоінти, розбіжність > ε → алерт у Telegram) — закриває 11,5-місячну дірку виявлення.
- Guard на третю спільну колекцію
universities-search-params(її sync — той самий delete-then-recreate без guard-а). - Merge-конфлікт main↔dev у
syncOffersToTypesense: при злитті лишити alias-swap версію з main + ширше вікно років із dev (MIN_SUPPORTED_ADMISSION_YEAR..CURRENT, потрібне фічі NMT-2022-2023). Розписано в описі api#575. - Полагодити GH Actions біллінг в api-v2 — зараз жоден PR не має CI-гейта (падає за 3 с repo-wide).
Відкриті питання
Section titled “Відкриті питання”- Хто/що викликало
sync-typesenseо 19:10:36 — не встановлено (dev не пише access-логи, лишеhttp.log.error). Найімовірніше — ручний виклик у межах фічі NMT-2022-2023 (ендпоінт створений саме для неї, dev деплоївся тричі того дня). Викликач, imовірно, не знає, що (а) його імпорт не завершився (EOF + рестарт о 19:16) і (б) виклик зачепив прод. - Чому dev-контейнер рестартнув о 19:16:14 — кандидат: OOM від вантаження офферів усіх років (2022–2025) з реляціями. Якщо повториться — дивитись
stoppedReason/пам’ять одразу. - Фантомні 12 990 «2026» доків у старому індексі (рік, якого немає в БД) — артефакт даних на момент білда 2025-07-21; окремого розслідування не вартий після повного ребілда.
Ключові ідентифікатори
Section titled “Ключові ідентифікатори”| Поле | Значення |
|---|---|
| PR-и | api-v2 #574 → main · #575 → dev (обидва merged 10.07, задеплоєні) |
| Pipeline-и | abitly-prod-backend exec 560600dc (fix) · abitly-dev-backend exec 37badf0d (guard; auto) — ручний дубль 2f2a29d1 впав на immutable ECR tag, нешкідливо |
| ECS | кластер abitly-prod-backend, сервіс abitly-prod-backend, task-def :38 |
| Typesense | EC2 typesense.abitly.internal:8108 (спільний dev+prod); alias offers → offers_1783700945324_926561 (44 870 доків); ключ — SSM /abitly/prod/backend/TYPESENSE_API_KEY |
| Redis-кеш | Valkey abitly-prod-cache.un7tgy.ng.0001.euc1.cache.amazonaws.com:6379; префікс OffersController-getOffers:*; TTL env CACHE_TTL (дефолт 86400) |
| Приклад-комбо | uni 282 (ЛНУ ім. Франка) × spec 374 (C3 «Міжнародні відносини», 2025) → оффери 1441192, 1441196, 1478920, 1492787 |
NULL name_en ЗВО | universities_new ids 4760, 6803, 6917, 6743 |
| Адмін-тригери | POST /admin/offers/reindex-offers (prod, новий) · POST /admin/offers/reindex-search-params (з #548) · POST /admin/offers/sync-typesense (тільки dev, тепер guarded) |
| Bastion для тунелів | i-06c688bb89a71415f (Postgres 5432 / Valkey 6379 / Typesense 8108) |
- Пара list/count із різних джерел без звірки — тихий брехун. Лічильник із живої БД робить зламаний список «переконливо живим»: UI показує «(4)», і ніхто не шукає проблему роками. Якщо два шляхи даних відповідають на одне питання — потрібна автоматична звірка.
- Фікс класу ≠ фікс екземпляра. 03.07 полагодили
search-params(cron + guard + alias-swap), а сусідні колекції того ж класу (offers,universities-search-params) лишили як були — і клас вистрелив знову за тиждень. Закривати треба перелік, а не кейс. - Кожен шар кешу подовжує шлях «фікс задеплоєно → користувач це бачить». Реіндекс без інвалідації кешу відповідей — це фікс, який ніхто не побачить до TTL. Інвалідація — частина фіксу, а не опція.
- Верифікуй тим шляхом, яким ходить користувач. Браузер не робить запит
/offers(SSR), а мій «контрольний» curl мав іншийlimit→ інший кеш-ключ → інший результат. Верифікація повз реальний шлях дала і хибне «полагоджено», і хибне «зламано» в один вечір. - Реіндексатор без null-safety на реальних даних — вічно падаючий ребілд. Один
NULL name_enсеред 44 870 рядків валить весьmap(); fail-safe alias-swap при цьому мовчки лишає старий індекс — помилка тиха й перманентна. Перед деплоєм індексатора звір поля з реальними даними. - Операційні дії на спільній інфраструктурі видно всім середовищам. Поки dev і prod ділять Typesense, будь-який «безпечний dev-виклик» — це потенційна прод-зміна. Guard-и — паліатив; справжня межа — окремі інстанції/неймспейси.
Пов’язана документація
Section titled “Пов’язана документація”- Каталог спеціальностей — фіча, яку зачепило
- Abitly API — сервіс,
SEARCH_ENGINE, ендпоінти - Бази даних — Typesense/Valkey, спільна інстанція
- Проблеми з БД — тріаж Typesense/Valkey, тунелі
- Інцидент 2026-06-12 — попередній пост-мортем (інший клас)