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

Інцидент 2026-07-09/10 — каталог КП: «Показати результати (N)» і порожній список

Каталог спеціальностей на abitly.org — головний пошук КП. Abitly API обслуговує його двома різними шляхами даних:

  • GET /offers (список карток) — на prod читає Typesense (SEARCH_ENGINE не заданий → дефолт typesense): пошук по колекції offers повертає ids, потім Postgres догружає картки. Поверх — ручний Redis-кеш відповідей per-URL (OffersController-getOffers:GET:<url>:<query-json>…, TTL CACHE_TTL || 86400 с) у Valkey abitly-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).

ЧасПодіяСтан
~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:25Root 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:59PR 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:29GET /offers повз кеш → 0 по всіх роках; частину трафіку маскують закешовані відповіді (суміш до- і після-реіндексних)🔴
19:16:07dev-логи: http.log.error EOF (обрив клієнтського зʼєднання) → 19:16:14 рестарт Nest на dev. Імпорт так і не відбувся — тому колекція лишилась з 0 доків🔴 механізм зафіксовано
~19:14(паралельно, ще до виявлення clobber) знайдено шар 3: Redis-кеш відповідей; перший флуш 4 864 ключів OffersController-getOffers:*🟡
~19:25Live-запити всі 0 → інтроспекція Typesense через bastion-тунель: alias offersнеіснуюча колекція; поруч порожня справжня offers (created 19:10:36)🟡 clobber доведено
19:29:05POST /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:41SSR-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-ом і кешем).

Шар 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-recreate createOffersCollection видалено.
  • 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 (guard x-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-версію syncOffersToTypesensesync-typesense на dev тепер чесно відповідає skipped і не пише в спільну колекцію. 1041/1041 тестів.

Ops (відновлення):

Terminal window
# 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, 202421 191 == 21 191 (було 0 vs 21 191)
/offers vs /offers/count, 202523 679 == 23 679 (було 36 811 vs 23 679)
Комбо ЛНУ×C3, SSR-набір параметрів4 оффери [1441196, 1492787, 1478920, 1441192]
SSR-HTML сторінкиусі 4 назви КП присутні
Браузер (Playwright)4 картки + ЛНУ видно
Автокомпліт (регресія)F1 → id 303, «Львів» → 5 ЗВО + регіон — без змін
  • 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).
  1. Хто/що викликало sync-typesense о 19:10:36 — не встановлено (dev не пише access-логи, лише http.log.error). Найімовірніше — ручний виклик у межах фічі NMT-2022-2023 (ендпоінт створений саме для неї, dev деплоївся тричі того дня). Викликач, imовірно, не знає, що (а) його імпорт не завершився (EOF + рестарт о 19:16) і (б) виклик зачепив прод.
  2. Чому dev-контейнер рестартнув о 19:16:14 — кандидат: OOM від вантаження офферів усіх років (2022–2025) з реляціями. Якщо повториться — дивитись stoppedReason/пам’ять одразу.
  3. Фантомні 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
TypesenseEC2 typesense.abitly.internal:8108 (спільний dev+prod); alias offersoffers_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)
  1. Пара list/count із різних джерел без звірки — тихий брехун. Лічильник із живої БД робить зламаний список «переконливо живим»: UI показує «(4)», і ніхто не шукає проблему роками. Якщо два шляхи даних відповідають на одне питання — потрібна автоматична звірка.
  2. Фікс класу ≠ фікс екземпляра. 03.07 полагодили search-params (cron + guard + alias-swap), а сусідні колекції того ж класу (offers, universities-search-params) лишили як були — і клас вистрелив знову за тиждень. Закривати треба перелік, а не кейс.
  3. Кожен шар кешу подовжує шлях «фікс задеплоєно → користувач це бачить». Реіндекс без інвалідації кешу відповідей — це фікс, який ніхто не побачить до TTL. Інвалідація — частина фіксу, а не опція.
  4. Верифікуй тим шляхом, яким ходить користувач. Браузер не робить запит /offers (SSR), а мій «контрольний» curl мав інший limit → інший кеш-ключ → інший результат. Верифікація повз реальний шлях дала і хибне «полагоджено», і хибне «зламано» в один вечір.
  5. Реіндексатор без null-safety на реальних даних — вічно падаючий ребілд. Один NULL name_en серед 44 870 рядків валить весь map(); fail-safe alias-swap при цьому мовчки лишає старий індекс — помилка тиха й перманентна. Перед деплоєм індексатора звір поля з реальними даними.
  6. Операційні дії на спільній інфраструктурі видно всім середовищам. Поки dev і prod ділять Typesense, будь-який «безпечний dev-виклик» — це потенційна прод-зміна. Guard-и — паліатив; справжня межа — окремі інстанції/неймспейси.

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

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