# Продуктивність і надійність — що виправлено

Прохід по коду з фокусом на: N+1 запити, відсутні індекси, неатомарні
багатокрокові записи, і місця де збій другорядної підсистеми міг би завалити
основний запит. **Форма відповідей на ендпоінтах не змінилась.**

## N+1 запити → пакетні запити

| Де було | Проблема | Що зроблено |
|---|---|---|
| `TopicService::tree()` | Рекурсивно: 1 запит на кожен topic-вузол + 1 на кожен лист за матеріалами — десятки round-trip'ів на предмет, **без кешу**, на кожен запит | 3 пакетні запити (roots, children по `IN`, materials по `IN`) + збірка дерева в пам'яті + кеш 24г (`topics:{subject}:tree`), інвалідація при збереженні матеріалу |
| `CatalogService::list()` | `saved->forTest()` викликався в циклі — по одному читанню кешу на кожен тест у списку | Кеш-мапа результатів користувача (`saved->all()`) читається один раз до циклу |
| `HomeworkService::listFor()` | Окремий `COUNT(*)` запит на кожне ДЗ учителя; `workspacePartner()` (свій же партнер, той самий щоразу) резолвився в кожній ітерації | Один згрупований `COUNT ... GROUP BY hwCode`; workspace резолвиться один раз до циклу |
| `InsightsService::painPoints()` | `dominantError()` (окремий запит) виконувався для **всіх** слабких навичок, а обрізались до `$limit` вже після | Ранжування й `take($limit)` — до важкого пер-скіл запиту; тепер максимум `$limit` зайвих запитів замість «скільки слабких навичок є» |

## Атомарність / надійність запису

| Місце | Було | Стало |
|---|---|---|
| `AttemptService::storeSimulation()` | Дві незалежні вставки (частина 1, частина 2) без транзакції — збій між ними лишав осиротілу непарну частину | Обгорнуто в `DB::transaction()` |
| `TelemetryService::finalize()` | Запис per-question статистики → оновлення мастері → generation поради → апдейт `attempts` → оновлення дошки — 5 нетранзакційних кроків; крах посередині лишав "submitted"-спробу без частини даних | Усе — в одній `DB::transaction()`; плюс **ідемпотентність**: повторний виклик на вже `submitted` спробі не подвоює лічильники мастері й не перегенеровує суперечливу пораду — повертає раніше збережену |
| `POST /attempts` — телеметрія в критичному шляху | Виняток у телеметрії (кривий payload, тимчасова помилка БД) міг перетворити **вже успішно оцінений і збережений** результат тесту на `500` | `AttemptController::recordTelemetry()` — весь виклик телеметрії обгорнуто в `try/catch`; збій логується (`report()`), відповідь все одно `200` з `attemptId: null, advice: null` |
| `NotificationService::pushJob()` | Необроблений виняток Redis міг завалити виклик, що його ініціював (збереження тесту в редакторі, крон стріків) | `pushJob()` тепер сам ловить помилки, логує і повертає `false`; додатково `EditorController::saveTest` обгортає Centrifugo/notify-виклики в `rescue()` |

## Індекси (нові міграції, `hasTable`-guarded — безпечно на будь-якій БД)

- `results(userID, testID)` — перевірка дубля результату й пошук збереженого проходження
- `results_hw(hwCode, pairID)` — підрахунок завершень домашки
- `results_hw(name, hwCode, resTime)` — перевірка дубля здачі ДЗ
- `attempts(userId, subject, status)`, `attempts(hwCode, status)` — інсайти/поради/integrity
- `attempt_question_stats(subject, answered, submittedAt)` — pace-index когорти й baseline у integrity

## Що свідомо не чіпали (і чому)

- **`/me/insights`** — не кешували на рівні відповіді: дані повʼязані з діями
  користувача (pin/dismiss) і мають відображатись одразу, а не через TTL.
  Замість цього — зменшили кількість запитів усередині (`painPoints`).
- **`BannerService`/`MisconceptionService`** — лишились дрібні пер-item запити
  (banner з `mainBanner`, `theory()` на misconception-item) — обсяги малі
  (одиниці-десятки рядків), і обидва вже за кешем/на некритичному, нечастому шляху.
- Зміни фокусувались на бекенді; фронтенд і продове розгортання (opcache,
  `config:cache`, `route:cache`, воркер черги) — див. README.
