Skip to content

Latest commit

 

History

History
268 lines (207 loc) · 23.1 KB

File metadata and controls

268 lines (207 loc) · 23.1 KB

🎯 Project 2 Day 9 Answers: InsightPulse — Вирішення N+1 у GraphQL (DataLoader) та захист API

Цей документ містить практичне резюме та аналіз результатів Завдання 1 (index.day_9.ts), присвяченого реалізації власного батчера-кешера (Custom BatchLoader) на базі черги мікротасок Event Loop, а також вичерпні відповіді на питання для самоперевірки та технічного інтерв'ю із завдання p-2_d-4.md.


🧩 Практичне резюме: Завдання 1. Алгоритмічний розігрів (Custom BatchLoader)

Код реалізації та демонстраційні тести знаходяться у файлі index.day_9.ts.

🎯 Мета завдання

  1. Зрозуміти внутрішню архітектуру та механіку бібліотеки dataloader від GraphQL Foundation шляхом створення спрощеної версії класу BatchLoader<K, V> на чистому TypeScript без зовнішніх бібліотек.
  2. Реалізувати перехоплення поодиноких асинхронних викликів load(key) протягом одного синхронного тіка JavaScript Event Loop та їхнє об'єднання в єдиний пакетний виклик batchFn(keys).
  3. Забезпечити кешування промісів (Promise Memoization) для виключення дублювання однакових запитів під час виконання (in-flight deduplication).
  4. Реалізувати безпечну ізоляцію поточної черги від наступних запитів та коректну адресну обробку результатів і помилок.

🛠️ Архітектура рішення: Черга мікротасок Event Loop та кешування Promise

Алгоритм базується на фазах Event Loop у JavaScript: усі синхронні виклики load() виконуються в поточному стеку викликів (Call Stack), а відправка сформованої пачки планується в черзі мікротасок за допомогою queueMicrotask().

[ Синхронний Call Stack ]
  userLoader.load("u1")  ──► Cache miss ──► Створено p1, додано в queue, заплановано microtask
  userLoader.load("u2")  ──► Cache miss ──► Створено p2, додано в queue
  userLoader.load("u1")  ──► Cache HIT  ──► Повернуто p1 (без додавання в чергу!)
  userLoader.load("u3")  ──► Cache miss ──► Створено p4, додано в queue
  Promise.all(...)       ──► Очікує завершення p1, p2, p4
             │
             ▼
[ Черга мікротасок (Microtask Queue) ]
  Спрацьовує dispatch()  ──► Зріз черги currentQueue = this.queue; this.queue = []
                         ──► keys = ["u1", "u2", "u3"]
                         ──► batchFn(keys) викликається РІВНО 1 РАЗ
                                    │
                                    ▼ (після відповіді БД)
                         ──► Результати: [Alice, Bob, Charlie]
                         ──► currentQueue[0].resolve(Alice) ──► p1 і p3 резолвляться!
                         ──► currentQueue[1].resolve(Bob)   ──► p2 резолвиться!
                         ──► currentQueue[2].resolve(Charlie) ──► p4 резолвиться!

Ключові інженерні акценти:

  1. Кешування Promise, а не значення (Map<K, Promise<V>>):

    if (this.cache.has(key)) {
      return this.cache.get(key)!;
    }
    const promise = new Promise<V>((resolve, reject) => {
      this.queue.push({ key, resolve, reject });
    });
    this.cache.set(key, promise);

    Збереження в кеш екземпляра Promise до початку його виконання дозволяє кільком паралельним викликам з однаковим ID підписатися на той самий результат. Це запобігає виникненню повторних паралельних запитів на вибірку одного й того самого ресурсу.

  2. Стандартизоване планування мікротаски (queueMicrotask):

    if (!this.isDispatchScheduled) {
      this.isDispatchScheduled = true;
      queueMicrotask(() => this.dispatch());
    }

    Використання офіційного стандарту W3C/ECMAScript queueMicrotask забезпечує повну сумісність як з Node.js, так і з браузерами без потреби завантаження додаткових типів @types/node. Планувальник спрацьовує строго один раз на пачку.

  3. Атомарний зріз черги перед асинхронним викликом:

    private dispatch(): void {
      this.isDispatchScheduled = false;
      const currentQueue = this.queue;
      this.queue = [];
      // ...
    }

    Очищення this.queue до await this.batchFn(...) запобігає втраті даних: будь-які запити load(), що надійдуть під час очікування відповіді від БД, будуть акумулюватися у нову пачку.

  4. Строге узгодження індексів при резолві:

    results.forEach((result, index) => {
      if (result instanceof Error) {
        currentQueue[index].reject(result);
      } else {
        currentQueue[index].resolve(result);
      }
    });

    Кожен елемент results[index] відповідає елементу черги currentQueue[index]. Якщо елемент є екземпляром Error, відповідний проміс індивідуально реджектиться, не порушуючи роботу решти елементів пачки.


📊 Фактичні результати виконання (index.day_9.ts)

Тестовий стенд емулює 4 одночасні виклики load() для ключів u1, u2, u1 (повторний) та u3:

const p1 = userLoader.load("u1");
const p2 = userLoader.load("u2");
const p3 = userLoader.load("u1"); // Кеш
const p4 = userLoader.load("u3");

const [u1, u2, u1Cached, u3] = await Promise.all([p1, p2, p3, p4]);

Результат запуску у терміналі:

--- Starting parallel calls to load() ---
[Database Query #1] Querying the database for keys: [ 'u1', 'u2', 'u3' ]
Results: {
  u1: { id: 'u1', name: 'Alice' },
  u2: { id: 'u2', name: 'Bob' },
  u1Cached: { id: 'u1', name: 'Alice' },
  u3: { id: 'u3', name: 'Charlie' }
}
✅ Batcher test passed successfully!
  • Кількість запитів до БД: $1$ (замість $3$ або $4$).
  • Ідентичність посилання кешу: u1 === u1Cached повертає true.

🎯 Відповіді на питання для самоперевірки та інтерв'ю


1. Життєвий цикл кешу DataLoader (Per-Request Scope vs Singleton)

Питання: Чому екземпляр DataLoader категорично не можна реєструвати як стандартний NestJS Singleton (@Injectable() за замовчуванням)? До яких двох критичних наслідків це призведе (безпека/витік даних між користувачами та актуальність даних)?

Відповідь:

У фреймворку NestJS усі сервіси та провайдери за замовчуванням створюються в області видимості Singleton — тобто екземпляр класу створюється один раз при старті застосунку і живе в пам'яті протягом усього життєвого циклу процесу Node.js.

Використання DataLoader як Singleton призводить до двох катастрофічних наслідків:

1. Витік конфіденційних даних між користувачами (Cross-Tenant Security Breach)

  • DataLoader кешує завантажені сутності за їхнім ключем (id). Якщо лоадер спільний для всього сервера, дані, які завантажив Користувач $A$ (наприклад, приватні звіти, налаштування або персональні дані), осідають у кеші пам'яті this.cache.
  • Коли Користувач $B$ робить запит на той самий id, лоадер негайно повертає закешований Promise безпосередньо з оперативної пам'яті.
  • Запит узагалі не доходить до бази даних, оминаючи всі SQL/NoSQL перевірки прав доступу, фільтри по tenantId та механізми безпеки (Row-Level Security). Користувач $B$ отримує доступ до чужої приватної інформації.

2. Протухання даних (Stale Data) та неконтрольований витік пам'яті (Memory Leak)

  • Stale Data: Якщо будь-який запис у базі даних оновився або видалився (через GraphQL Mutation або фоновий воркер), синглтон-лоадер ніколи про це не дізнається. Усі подальші запити до сервера повертатимуть застарілі дані з пам'яті тижнями, аж до перезавантаження процесу.
  • Memory Leak: Оскільки внутрішня Map не має автоматичної стратегії витіснення (TTL або LRU), кожен новий згенерований ID зберігатиметься в пам'яті сервера назавжди. Це неминуче призведе до вичерпання ліміту пам'яті Node.js (JavaScript heap out of memory) та аварійної зупинки інстансу.

✅ Правильний архітектурний підхід:

DataLoader завжди повинен мати життєвий цикл Per-Request Scope:

  1. Створюватися окремо для кожного вхідного HTTP-запиту (найчастіше у GraphQL Context через фабрику або через @Injectable({ scope: Scope.REQUEST })).
  2. Кешувати дані виключно в межах обробки одного поточного GraphQL-запиту.
  3. Після відправки HTTP-відповіді клієнту екземпляр лоадера разом із кешем автоматично утилізується Garbage Collector'ом.

2. Вимога до порядку та довжини поверненого масиву

Питання: Чому пакетна функція DataLoader зобов'язана повертати масив такої ж самої довжини, що й вхідний масив ключів, і зберігати точну відповідність індексів? Що станеться, якщо просто повернути result.filter(Boolean)?

Відповідь:

Клас DataLoader є універсальним інструментом (BatchLoader<K, V>), який нічого не знає про предметну область та структуру сутностей (він не аналізує наявність полів id, _id, uuid тощо всередині повернених об'єктів).

Єдиним зв'язком між запитаним ключем keys[i] та результатом results[i] є індекс у масиві: $$\text{results}[i] \longleftrightarrow \text{keys}[i]$$

Що станеться при використанні result.filter(Boolean):

Розглянемо практичний приклад:

  1. Клієнти запитали трьох користувачів: keys = ['u1', 'u2', 'u3'].
  2. Користувач u2 був раніше видалений з бази даних.
  3. Запит до БД find({ _id: { $in: keys } }) повернув масив з двох існуючих записів:
    [ { id: 'u1', name: 'Alice' }, { id: 'u3', name: 'Charlie' } ]
  4. Якщо пакетна функція поверне цей відфільтрований масив довжиною $2$ замість $3$:
    • Індекс 0: keys[0] ('u1') зв'язується з results[0] (Alice) — вірно.
    • Індекс 1: keys[1] ('u2') зв'язується з results[1] (Charlie) — КРИТИЧНА ПОМИЛКА. Запит для u2 отримує дані зовсім іншого користувача Charlie!
    • Індекс 2: keys[2] ('u3') зв'язується з results[2] (undefined) — запит для існуючого користувача u3 повертає undefined або падає з помилкою, хоча сутність є в БД!

Відбувається зсув індексів (Index Shift), що призводить до тихого пошкодження або підміни даних.

✅ Правило контракту DataLoader:

Пакетна функція завжди повинна формувати маппінг через словник (Map) або об'єкт і повертати значення для кожного запитаного ключа:

const userMap = new Map(users.map((u) => [u.id, u]));
return keys.map((key) => userMap.get(key) ?? null);
// або: userMap.get(key) ?? new Error(`User ${key} not found`)

Якщо сутність не знайдена, її місце в масиві результатів повинен займати null або екземпляр Error.


3. DoS-атаки на GraphQL у Production

Питання: Які типи атак є специфічними саме для GraphQL на відміну від класичного REST API? Чому відключення Introspection у продакшні є гарною практикою, але не вирішує проблему шкідливих циклічних запитів?

Відповідь:

У класичному REST API сервер надає жорстко регламентовані ендпоінти з фіксованими схемами відповідей. У GraphQL клієнт самостійно визначає структуру та форму графа даних, що відкриває специфічні вектори DoS-атак:

Специфічні GraphQL-атаки:

  1. Глибокі циклічні запити (Nested / Circular Queries): Якщо в схемі є циклічні зв'язки (наприклад, Category -> Product -> Category -> Product), зловмисник може надіслати запит з екстремальною вкладеністю (наприклад, 100 рівнів). Сервер почне рекурсивно виконувати резолвери, що викличе вичерпання пам'яті та зависання Event Loop.
  2. Атаки експоненційної складності (Query Complexity / Exponential Fan-out): Запит може мати невелику глибину, але запитувати зв'язки «один-до-багатьох»: 100 авторів $\times$ 100 постів $\times$ 100 коментарів = $1,000,000$ вузлів даних в одному запиті. Сервер витратить гігабайти пам'яті на серіалізацію JSON і перевантажить БД.
  3. Обхід Rate Limiting через аліаси (Alias Batching Attack): У REST API для перебору 10 000 паролів потрібно зробити 10 000 HTTP-запитів POST /login, які легко блокуються WAF або Nginx. У GraphQL можна надіслати один-єдиний HTTP POST-запит з 10 000 аліасів однієї мутації:
    mutation BruteForce {
      a1: login(user: "admin", pass: "pass1") { token }
      a2: login(user: "admin", pass: "pass2") { token }
      # ... ще 9998 аліасів
    }
    Стандартний IP-обмежувач бачить лише 1 вхідний HTTP-запит і пропускає атаку.

Чому відключення Introspection не захищає від атак:

  • Відключення Introspection у продакшні приховує документацію схеми від інструментів на зразок GraphQL Playground чи Postman.
  • Проте це лише Security through Obscurity (безпека через невідомість). Якщо зловмисник витягне назви полів із відкритого клієнтського JS-бандла фронтенду або просто підбере кілька базових назв (users, posts, id), він без проблем сформує шкідливий циклічний запит.

✅ Комплексний захист GraphQL API:

  • Query Depth Limiting (наприклад, graphql-depth-limit): перевіряє AST запиту перед виконанням і відхиляє запити, глибина яких перевищує встановлений поріг (наприклад, максимум 5–7 рівнів).
  • Query Complexity Analysis (наприклад, graphql-query-complexity): кожному полю призначається вартість (наприклад, скаляр = 1 бал, список = 10 балів), і запити з вагою понад ліміт (наприклад, 1000) блокуються до старту резолверів.
  • GqlThrottlerGuard: рейт-лімітинг на рівні GraphQL-контексту.

4. Automatic Persisted Queries (APQ)

Питання: Як технологія APQ вирішує проблему розміру мережевого пакета при передачі великих GraphQL-запитів по HTTP і яким чином вона дає змогу використовувати стандартне CDN-кешування (HTTP GET) для запитів на читання?

Відповідь:

Проблема класичного GraphQL через HTTP:

  1. Тексти реальних GraphQL-запитів на клієнті (з фрагментами, вибірками десятків полів) можуть сягати 20–100 КБ. Передавати такий обсяг тексту при кожному кліку — це надлишковий мережевий оверхед, що сповільнює мобільні клієнти.
  2. Оскільки тіло запиту велике, GraphQL традиційно відправляє всі операції через HTTP POST. Але за стандартом протоколу HTTP запити POST не кешуються браузерами, проміжними проксі та мережами доставки контенту (CDN).

Механіка роботи APQ (наприклад, Apollo Server):

Замість постійної відправки довгого рядка GraphQL SDL клієнт і сервер домовляються передавати лише фіксований криптографічний хеш запиту (SHA-256):

Клієнт                                                 Сервер / CDN
  │                                                         │
  ├─ 1. HTTP GET /graphql?extensions={sha256Hash:"a3b8..."} ─►
  │     (Дуже компактний пакет: ~100 байт)                  │
  │                                                    Чи є хеш в кеші?
  │                                                         ├── ТАК: Виконати запит і віддати JSON
  │                                                         │
  │  ◄─ 2. Помилка PERSISTED_QUERY_NOT_FOUND (якщо немає) ──┘
  │
  ├─ 3. HTTP POST /graphql (надсилає sha256Hash + повний текст SDL) ─►
  │                                                         │
  │                                                    Зберегти хеш ──► SDL у Redis / LRU
  │                                                    Виконати запит
  │  ◄─ 4. Успішна відповідь JSON ──────────────────────────┘
  │
  └─ Наступні виклики цього ж запиту знову йдуть як легкий HTTP GET ──►

Ключові інженерні переваги APQ:

  1. Радикальна оптимізація пропускної спроможності (Bandwidth): Після першого збереження запиту клієнт передає лише 64-символьний рядок хешу замість багатокілобайтного тексту запиту.
  2. Повноцінне використання CDN-кешування (Edge Caching): Оскільки запити на читання відправляються методом HTTP GET з детермінованим хешем у query-параметрах (GET /graphql?extensions={"persistedQuery":{...}}), такі запити повністю відповідають специфікації HTTP-кешування. Мережі CDN (Cloudflare, AWS CloudFront, Fastly) можуть кешувати JSON-відповіді відповідно до заголовків Cache-Control, віддаючи дані користувачам із найближчого географічного edge-вузла без навантаження на основні сервери застосунку.