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

Data-архітектура вступної кампанії

Ця сторінка — єдина мапа того, як дані вступної кампанії рухаються системою: від парсингу ЄДЕБО до симуляції вступу і сторінок сайту. Спочатку — стан as-is (з усіма слабкими місцями), потім — цільова архітектура і роадмап. Суміжні сторінки: containers, databases, abitly-parse, vstup-simulation.

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), але не для інжесту.

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
СховищеЩо лежитьХто пишеХто читаєВерсіонування
Postgres схема abitly (довідники)universities_static, offers, specialities, faculties, branchesabitly-parse (upsert)api-v2, бот, симуляціянемає — in-place upsert; freshness у table_metadata
Postgres abitly.offer_requests~1.5M заяв + prediction_statusabitly-parse; prediction — vstup-simulationapi-v2 (сторінки КП)немає — перезапис
Postgres схема wide_competitionruns/groups/lanes/applications/offer_cutoffsvstup-simulationapi-v2✅ run_id, draft→active→archived, prune keep 3
Postgres схема admission_analyticsснапшоти агрегатів вступуapi-v2 (populate)api-v2 (/analytics)snapshot-based
Похідні в abitlyoffers.score_map, MV universities, subjects_score_data, item_item_edgesapi-v2 admin-ендпоінти / офлайн-батчіapi-v2item_item_edges — ✅ snapshot_id; MV — ❌ ручний refresh
Valkey (dev/prod окремі)response-cache, BullMQapi-v2api-v2ключі без версії даних; TTL 60с–24год
Typesense (⚠️ один на dev+prod)колекції offers, search-paramsapi-v2 prod (cron + bootstrap)api-v2 prodalias-swap (zero-downtime), захист guard’ом SEARCH_ENGINE
Файловий кеш парсера (EFS + локальний)сирі відповіді джерелabitly-parseabitly-parsefingerprints; 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-міграції: migrationAssert guards + CI-перевірка data-міграцій; statement_timeout=30s.
  • Typesense: alias-swap відкидає збірку при per-document failures; guard SEARCH_ENGINE=orm на dev.

Кожен пункт — підтверджений інцидентом або видимий з коду.

#Слабке місцеНаслідокДоказ
1Спільний Typesense dev+prod, захист лише env-змінноюdev clobber-ить prod-індексінцидент 09.07
2Prod-міграції ручні, деплой їх не запускаєкод і схема роз’їжджаються → 500/краш-лупінцидент 16.07
3Redis response-cache інвалідується лише TTL (до 24 год)маскує і фікси, і поламки данихінцидент 09.07
4Інжест пише в dev і prod одночасно — dev не є перевірочним контуром для данихбитий парс одразу на продідизайн refresh-current --targets dev,studsearchprod
5prediction_status перезаписується без run_idнема відкату прогнозів, нема порівняння ранівкод wide-db-writers.mjs
6Інкрементальний fetch не бачить нові КП у закешованих ЗВО«зникаючі» пропозиції, стейл-каталогpipeline.py (задокументовано в самому коді)
7MV 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_HOSTiac
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)”
  1. Один напрям потоку + promotion-гейт. Інжест пише лише в staging-контур; на prod дані потрапляють атомарним промоушеном (swap версії) після автоматичних перевірок. Найдешевша реалізація без нової інфри: версіоновані батчі в тій самій БД.
  2. Версія на кожен артефакт. Кожен load парсера = import_id, кожен прогноз = run_id (вже є), кожен пошуковий індекс = версія колекції (вже є через alias). Serving завжди читає «активну» версію; відкат = перемикання вказівника, а не відновлення з бекапу.
  3. Ізоляція середовищ. Жоден dev-процес фізично не може писати у prod-ресурс: окремий Typesense (або namespaced колекції з окремими ключами), окремі IAM/креденшели інжесту для dev і prod.
  4. Схема їде разом з кодом. Міграції — крок пайплайна (з manual approval для prod), а не ручна сесія через bastion.
  5. Кеш інвалідується подією. Load/activate/reindex завершується purge-ом відповідних ключів; TTL — лише запасний механізм.
  6. Freshness — це SLO. Алерти на вік table_metadata, вік активного wide-run, вік alias у Typesense, вік refresh MV. Мета: дізнаватися про поламку раніше за користувачів.
  7. Оркестрація в хмарі, рішення — за людиною. Wide-run як ECS-таска з тими самими gates; людина лише тисне «activate» (і може це зробити через Claude Code з будь-якої машини).
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: між «дані спарсились» і «дані бачать користувачі» з’являється явний, автоматично перевірений і відкочуваний крок, а всі перемикання (батч, ран, індекс) — версіоновані вказівники.

Хвилі впорядковані за співвідношенням «зняте ризику / зусилля». Кожен крок самодостатній.

Хвиля 1 — прибрати найдорожчі failure modes (дні)

Section titled “Хвиля 1 — прибрати найдорожчі failure modes (дні)”
  1. Розділити Typesense dev/prod (другий контейнер на тому ж EC2 або namespaced колекції з різними API-ключами) — закриває клас cross-env clobber назавжди (слабке місце №1).
  2. Freshness-алерти (CloudWatch): вік table_metadata.last_updated, вік активного wide_competition.runs, вік Typesense alias, вік MV refresh (№10, №7).
  3. Event-based purge Redis після load парсера і reindex (у симуляції вже є) (№3).
  4. Плановий повний --refresh offers (наприклад, нічний четвертий запуск) — закриває «невидимі нові КП» (№6).
  5. Автоматичний REFRESH MATERIALIZED VIEW CONCURRENTLY для universities — cron у api-v2 або EventBridge (№7).
  6. Стійкість інжесту до блокування джерелом, мінімум: CloudWatch alarm failed/stale на parser-таску (готовий патерн ads-audience-sync у тому ж TF-репо) + класифікація 200 + порожнє тіло як підозри на блок + fewer-entries gate для offers (аналог наявного для заяв) (№14).

Хвиля 2 — версіонування і промоушен (тижні)

Section titled “Хвиля 2 — версіонування і промоушен (тижні)”
  1. import_id на батчі парсера: лог імпортів (таблиця import_runs: джерело, обсяги, дельти, статус) + діф-звіт після кожного load; gate «дельта підозріло велика → не промоутити» (№4).
  2. Промоушен замість dual-write: parser пише у staging (dev-БД або staging-таблиці prod), автоматичні перевірки, потім атомарний promote у prod (swap/merge) (№4).
  3. run_id для прогнозів: prediction_status версіонується (колонка run_id або окрема таблиця) → відкат і порівняння ранів (№5).
  4. Міграції в пайплайні: CodeBuild-крок migration:run з manual approval для prod (№2).
  5. S3-архів кожного parse-рану заяв: load offer_requests додатково пише снапшот jsonl.gz у S3 (partition year=/table=/dt=/import_id=), Athena external table зверху; timeline подачі по днях = MIN(dt) появи заяви. Обсяг ~десятки МБ/ран gzip — копійки; lifecycle у Glacier після сезону (№13).
  6. Проксі-рубильник для інжесту: документована HTTPS_PROXY у env таски (requests підтримує з коробки) + резидентний/український проксі напоготові на випадок бану AWS-діапазонів; ретрай 403 в osvita-клієнті, UA-ротація, scrapling StealthyFetcher як fallback-транспорт (№14).

Хвиля 3 — стійкість і масштаб (місяці)

Section titled “Хвиля 3 — стійкість і масштаб (місяці)”
  1. Wide-run у хмарі: ECS-таска з тим самим preflight/dry-run/verify; активація — підтвердження людини (через Claude Code / CLI з будь-якої машини) (№9).
  2. Розділення RDS-навантаження: read-replica для важких read-запитів (offer_requests, аналітика) — за прикладом abitly-mcp-replica; ліміт на fan-out запитів (№8).
  3. Єдиний власник кожної таблиці: зафіксувати в ADR, хто пише кожну таблицю (парсер / api-v2 / симуляція / людина), і заборонити решту шляхів запису на рівні DB-грантів (№11).
  4. Повне покриття IaC: завести prod-backup-for-stage, sim-funnel, yangon у Terraform або явно списати (№12).