Data-архітектура вступної кампанії
Ця сторінка — єдина мапа того, як дані вступної кампанії рухаються системою: від парсингу ЄДЕБО до симуляції вступу і сторінок сайту. Спочатку — стан as-is (з усіма слабкими місцями), потім — цільова архітектура і роадмап. Суміжні сторінки: containers, databases, abitly-parse, vstup-simulation.
Поточний стан (as-is)
Section titled “Поточний стан (as-is)”Загальна мапа потоків
Section titled “Загальна мапа потоків”flowchart LR
subgraph src[Зовнішні джерела]
edbo[ЄДЕБО<br/>vstup.edbo.gov.ua]
registry[ЄДЕБО registry]
abitpoisk[abit-poisk.org.ua<br/>fallback + ПІБ]
osvita[vstup.osvita.ua<br/>квоти]
end
subgraph parse[abitly-parse · ECS scheduled 3×/день]
cache[Файловий кеш EFS<br/>jsonl.gz + fingerprints]
buildstep[build → link → gates<br/>→ upsert.sql]
end
subgraph pg[Postgres · спільна інстанція studsearch-prod + окрема abitly-dev-pg]
ref[Довідники: universities_static,<br/>offers, specialities, faculties]
req[offer_requests ~1.5M<br/>+ prediction_status]
wc[схема wide_competition:<br/>runs, lanes, applications, offer_cutoffs]
derived[Похідні: score_map, MV universities,<br/>subjects_score_data, item_item_edges]
end
subgraph sim[vstup-simulation · ручний /wide-run]
engine[Рушій Додатка 6<br/>preflight → dry-run → activate]
end
subgraph serving[abitly-api-v2 · ECS Fargate]
api[NestJS API]
redis[(Valkey · response-cache<br/>TTL 60с–24год)]
ts[(Typesense · shared dev+prod<br/>alias-swap, cron 04:07)]
end
edbo --> parse
registry --> parse
abitpoisk --> parse
osvita --> parse
cache --> buildstep
buildstep -- "upsert одночасно<br/>у dev І prod" --> ref
buildstep --> req
ref --> engine
req --> engine
engine --> wc
engine -- "activate: перезапис<br/>prediction_status" --> req
ref --> api
req --> api
wc --> api
derived --> api
api --> redis
api -- reindex --> ts
api --> web[Web / Mini App / Bot]
Ключова особливість as-is: немає промоушену dev→prod. Парсер пише в обидві БД одночасно (--targets dev,studsearchprod), тобто dev не є перевірочним контуром для даних — він отримує ті самі дані в той самий момент, що і prod. Перевірка «на dev, потім на prod» реальна лише для коду (CodePipeline dev → merge → main) і для ранів симуляції (--env dev → --env prod), але не для інжесту.
Доба даних у сезон
Section titled “Доба даних у сезон”sequenceDiagram
autonumber
participant P as abitly-parse (Fargate)
participant DB as Postgres (dev+prod)
participant S as vstup-simulation (оператор)
participant A as api-v2
participant T as Typesense
participant R as Valkey
Note over P: 09:00 / 14:00 / 20:00
P->>DB: refresh-current: upsert offers,<br/>offer_requests (dev і prod)
Note over S: вручну, коли вирішив оператор
S->>DB: зріз REPEATABLE READ
S->>S: симуляція + fairness verify
S->>DB: новий run у wide_competition,<br/>activate → prediction_status
S->>R: purge offer-applicants кешу (fail-safe)
Note over A: 04:07 щоночі + кожен деплой
A->>DB: читає offers/search-params
A->>T: reindex через alias-swap
Note over A,R: увесь трафік
A->>R: response-cache TTL 60с–24год<br/>інвалідація лише за TTL
Сховища і власники
Section titled “Сховища і власники”| Сховище | Що лежить | Хто пише | Хто читає | Версіонування |
|---|---|---|---|---|
Postgres схема abitly (довідники) | universities_static, offers, specialities, faculties, branches | abitly-parse (upsert) | api-v2, бот, симуляція | немає — in-place upsert; freshness у table_metadata |
Postgres abitly.offer_requests | ~1.5M заяв + prediction_status | abitly-parse; prediction — vstup-simulation | api-v2 (сторінки КП) | немає — перезапис |
Postgres схема wide_competition | runs/groups/lanes/applications/offer_cutoffs | vstup-simulation | api-v2 | ✅ run_id, draft→active→archived, prune keep 3 |
Postgres схема admission_analytics | снапшоти агрегатів вступу | api-v2 (populate) | api-v2 (/analytics) | snapshot-based |
Похідні в abitly | offers.score_map, MV universities, subjects_score_data, item_item_edges | api-v2 admin-ендпоінти / офлайн-батчі | api-v2 | item_item_edges — ✅ snapshot_id; MV — ❌ ручний refresh |
| Valkey (dev/prod окремі) | response-cache, BullMQ | api-v2 | api-v2 | ключі без версії даних; TTL 60с–24год |
| Typesense (⚠️ один на dev+prod) | колекції offers, search-params | api-v2 prod (cron + bootstrap) | api-v2 prod | alias-swap (zero-downtime), захист guard’ом SEARCH_ENGINE |
| Файловий кеш парсера (EFS + локальний) | сирі відповіді джерел | abitly-parse | abitly-parse | fingerprints; git-ignored, не бекапиться як дані |
Контрольні точки якості (що вже є)
Section titled “Контрольні точки якості (що вже є)”- Парсер: FK-preflight перед load, blocked-build при нерозпізнаних сутностях, fewer-entries safeguard, ідемпотентні upsert-и, fill-only для прохідних балів.
- Симуляція: preflight identity-assert БД, dry-run зі звітом, fairness-верифікатор (незалежний оракул), блокування activate при fatal-інваріантах, унікальний active-run на рік, транзакційний запис.
- API-міграції:
migrationAssertguards + CI-перевірка data-міграцій;statement_timeout=30s. - Typesense: alias-swap відкидає збірку при per-document failures; guard
SEARCH_ENGINE=ormна dev.
Слабкі місця as-is
Section titled “Слабкі місця as-is”Кожен пункт — підтверджений інцидентом або видимий з коду.
| # | Слабке місце | Наслідок | Доказ |
|---|---|---|---|
| 1 | Спільний Typesense dev+prod, захист лише env-змінною | dev clobber-ить prod-індекс | інцидент 09.07 |
| 2 | Prod-міграції ручні, деплой їх не запускає | код і схема роз’їжджаються → 500/краш-луп | інцидент 16.07 |
| 3 | Redis response-cache інвалідується лише TTL (до 24 год) | маскує і фікси, і поламки даних | інцидент 09.07 |
| 4 | Інжест пише в dev і prod одночасно — dev не є перевірочним контуром для даних | битий парс одразу на проді | дизайн refresh-current --targets dev,studsearchprod |
| 5 | prediction_status перезаписується без run_id | нема відкату прогнозів, нема порівняння ранів | код wide-db-writers.mjs |
| 6 | Інкрементальний fetch не бачить нові КП у закешованих ЗВО | «зникаючі» пропозиції, стейл-каталог | pipeline.py (задокументовано в самому коді) |
| 7 | MV universities без автоматичного refresh | протухлі агрегати ЗВО | код api-v2, інцидент 16.07 |
| 8 | Спільна RDS-інстанція під усі prod-сервіси; важкі запити по offer_requests | вичерпання EBS/пулу → сайт «лежить» при живому health | інцидент 17.07, db-issues |
| 9 | Оркестрація симуляції прив’язана до однієї Windows-машини (хардкод шляхів, ручний тунель) | bus-factor 1, нема запуску з хмари | код import-wide-run.mjs |
| 10 | Немає freshness-моніторингу (age даних, age активного рану, age індексу) | про поламки повідомляють користувачі | відсутність алертів в IaC |
| 11 | Довідники правляться і міграціями api-v2, і парсером, і руками | конфлікт власності: хибний is_supported | інцидент 01.07 |
| 12 | Частина data-інфри поза Terraform (prod-backup-for-stage, sim-funnel, yangon-БД) | «невидимі» ресурси, інцидент з підміною DB_HOST | iac |
| 13 | Історія станів заяв не зберігається: upsert перезаписує offer_requests in-place, сирий кеш truncate-иться при --refresh; дати подачі у даних ЄДЕБО немає | timeline подачі заяв по днях не відбудувати ретроспективно — «день подачі» можна відновити лише як перший снапшот, де з’явилась заява | дизайн upsert + pack --refresh; частковий обхід — агрегатна схема admission_timeline (dev, поза ядром) |
| 14 | Інжест не готовий до блокування джерелом: «тихий» блок (200 + порожнє тіло) приймається як валідні дані, для offers немає fewer-entries gate, проксі/обходу немає (весь egress — датацентрові IP AWS eu-central-1), алертів на фейл таски немає (свідомий scope cut у TF) | при тихому блоці можлива регресія каталогу на проді; при бані AWS-діапазонів парсер сліпне, і дізнаємось від користувачів. Жорсткий бан (403/429) безпечний: SystemExit, БД stale-but-intact | код abitly-parse common/http.py (200+[] → валідний результат), TF abitly-prod-web-parser («no failure alarm/SNS… deliberate scope cut»); червневий інцидент порожнього кешу abit-poisk «consistent with anti-bot throttling» (CLAUDE.md репо) |
Цільова архітектура (to-be)
Section titled “Цільова архітектура (to-be)”Принципи
Section titled “Принципи”- Один напрям потоку + promotion-гейт. Інжест пише лише в staging-контур; на prod дані потрапляють атомарним промоушеном (swap версії) після автоматичних перевірок. Найдешевша реалізація без нової інфри: версіоновані батчі в тій самій БД.
- Версія на кожен артефакт. Кожен load парсера =
import_id, кожен прогноз =run_id(вже є), кожен пошуковий індекс = версія колекції (вже є через alias). Serving завжди читає «активну» версію; відкат = перемикання вказівника, а не відновлення з бекапу. - Ізоляція середовищ. Жоден dev-процес фізично не може писати у prod-ресурс: окремий Typesense (або namespaced колекції з окремими ключами), окремі IAM/креденшели інжесту для dev і prod.
- Схема їде разом з кодом. Міграції — крок пайплайна (з manual approval для prod), а не ручна сесія через bastion.
- Кеш інвалідується подією. Load/activate/reindex завершується purge-ом відповідних ключів; TTL — лише запасний механізм.
- Freshness — це SLO. Алерти на вік
table_metadata, вік активного wide-run, вік alias у Typesense, вік refresh MV. Мета: дізнаватися про поламку раніше за користувачів. - Оркестрація в хмарі, рішення — за людиною. Wide-run як ECS-таска з тими самими gates; людина лише тисне «activate» (і може це зробити через Claude Code з будь-якої машини).
Мапа to-be
Section titled “Мапа to-be”flowchart LR
src[ЄДЕБО / abit-poisk / osvita] --> ingest
subgraph ingest[Інжест · ECS scheduled]
fetcher[fetch + кеш EFS]
loader[build + gates<br/>load import_id=N]
end
subgraph pgprom[Postgres]
staging[Staging: батч import_id=N<br/>+ автоперевірки обсягів/діфів]
active[Активна версія довідників<br/>і заяв]
wc2[wide_competition.runs<br/>run_id + звʼязок з import_id]
end
subgraph obs[Спостережуваність]
fresh[Freshness-алерти:<br/>age імпорту / рану / індексу]
diffrep[Діф-звіт батчів:<br/>рядки, дельти, аномалії]
end
subgraph serving2[Serving]
api2[api-v2]
redis2[(Valkey<br/>purge подією)]
tsdev[(Typesense dev)]
tsprod[(Typesense prod)]
end
s3arch[(S3 снапшот-архів<br/>jsonl.gz per import_id · Athena)]
fetcher --> loader
loader -- "кожен ран" --> s3arch
s3arch -.-> timeline[Timeline подачі заяв<br/>по днях · ретро-аналітика]
loader --> staging
staging -- "gate пройдено →<br/>атомарний promote" --> active
active --> wc2
wc2 -- "ECS-таска wide-run,<br/>activate за згодою людини" --> active
active --> api2
api2 --> redis2
api2 --> tsprod
loader -.-> diffrep
staging -.-> fresh
active -.-> fresh
api2 -.-> fresh
Принципова різниця з as-is: між «дані спарсились» і «дані бачать користувачі» з’являється явний, автоматично перевірений і відкочуваний крок, а всі перемикання (батч, ран, індекс) — версіоновані вказівники.
Роадмап
Section titled “Роадмап”Хвилі впорядковані за співвідношенням «зняте ризику / зусилля». Кожен крок самодостатній.
Хвиля 1 — прибрати найдорожчі failure modes (дні)
Section titled “Хвиля 1 — прибрати найдорожчі failure modes (дні)”- Розділити Typesense dev/prod (другий контейнер на тому ж EC2 або namespaced колекції з різними API-ключами) — закриває клас cross-env clobber назавжди (слабке місце №1).
- Freshness-алерти (CloudWatch): вік
table_metadata.last_updated, вік активногоwide_competition.runs, вік Typesense alias, вік MV refresh (№10, №7). - Event-based purge Redis після load парсера і reindex (у симуляції вже є) (№3).
- Плановий повний
--refreshoffers (наприклад, нічний четвертий запуск) — закриває «невидимі нові КП» (№6). - Автоматичний
REFRESH MATERIALIZED VIEW CONCURRENTLYдляuniversities— cron у api-v2 або EventBridge (№7). - Стійкість інжесту до блокування джерелом, мінімум: CloudWatch alarm failed/stale на parser-таску (готовий патерн
ads-audience-syncу тому ж TF-репо) + класифікація200 + порожнє тілояк підозри на блок + fewer-entries gate для offers (аналог наявного для заяв) (№14).
Хвиля 2 — версіонування і промоушен (тижні)
Section titled “Хвиля 2 — версіонування і промоушен (тижні)”import_idна батчі парсера: лог імпортів (таблицяimport_runs: джерело, обсяги, дельти, статус) + діф-звіт після кожного load; gate «дельта підозріло велика → не промоутити» (№4).- Промоушен замість dual-write: parser пише у staging (dev-БД або staging-таблиці prod), автоматичні перевірки, потім атомарний promote у prod (swap/merge) (№4).
run_idдля прогнозів:prediction_statusверсіонується (колонка run_id або окрема таблиця) → відкат і порівняння ранів (№5).- Міграції в пайплайні: CodeBuild-крок
migration:runз manual approval для prod (№2). - S3-архів кожного parse-рану заяв: load
offer_requestsдодатково пише снапшотjsonl.gzу S3 (partitionyear=/table=/dt=/import_id=), Athena external table зверху; timeline подачі по днях =MIN(dt)появи заяви. Обсяг ~десятки МБ/ран gzip — копійки; lifecycle у Glacier після сезону (№13). - Проксі-рубильник для інжесту: документована
HTTPS_PROXYу env таски (requests підтримує з коробки) + резидентний/український проксі напоготові на випадок бану AWS-діапазонів; ретрай 403 в osvita-клієнті, UA-ротація, scrapling StealthyFetcher як fallback-транспорт (№14).
Хвиля 3 — стійкість і масштаб (місяці)
Section titled “Хвиля 3 — стійкість і масштаб (місяці)”- Wide-run у хмарі: ECS-таска з тим самим preflight/dry-run/verify; активація — підтвердження людини (через Claude Code / CLI з будь-якої машини) (№9).
- Розділення RDS-навантаження: read-replica для важких read-запитів (offer_requests, аналітика) — за прикладом
abitly-mcp-replica; ліміт на fan-out запитів (№8). - Єдиний власник кожної таблиці: зафіксувати в ADR, хто пише кожну таблицю (парсер / api-v2 / симуляція / людина), і заборонити решту шляхів запису на рівні DB-грантів (№11).
- Повне покриття IaC: завести
prod-backup-for-stage, sim-funnel, yangon у Terraform або явно списати (№12).
Пов’язане
Section titled “Пов’язане”- Сервісні картки: abitly-parse, vstup-simulation, abitly-api
- Рішення: ADR-0013 offer lineage, ADR-0014 intelligence as data
- Інциденти-першоджерела: 01.07 ГК biotech, 09.07 Typesense, 16.07 MV migration, 17.07 EBS saturation