Цей документ містить практичне резюме та аналіз результатів Завдання 1 (index.day_9.ts), присвяченого реалізації власного батчера-кешера (Custom BatchLoader) на базі черги мікротасок Event Loop, а також вичерпні відповіді на питання для самоперевірки та технічного інтерв'ю із завдання p-2_d-4.md.
Код реалізації та демонстраційні тести знаходяться у файлі index.day_9.ts.
- Зрозуміти внутрішню архітектуру та механіку бібліотеки
dataloaderвід GraphQL Foundation шляхом створення спрощеної версії класуBatchLoader<K, V>на чистому TypeScript без зовнішніх бібліотек. - Реалізувати перехоплення поодиноких асинхронних викликів
load(key)протягом одного синхронного тіка JavaScript Event Loop та їхнє об'єднання в єдиний пакетний викликbatchFn(keys). - Забезпечити кешування промісів (Promise Memoization) для виключення дублювання однакових запитів під час виконання (in-flight deduplication).
- Реалізувати безпечну ізоляцію поточної черги від наступних запитів та коректну адресну обробку результатів і помилок.
Алгоритм базується на фазах 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 резолвиться!
-
Кешування 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 підписатися на той самий результат. Це запобігає виникненню повторних паралельних запитів на вибірку одного й того самого ресурсу. -
Стандартизоване планування мікротаски (
queueMicrotask):if (!this.isDispatchScheduled) { this.isDispatchScheduled = true; queueMicrotask(() => this.dispatch()); }
Використання офіційного стандарту W3C/ECMAScript
queueMicrotaskзабезпечує повну сумісність як з Node.js, так і з браузерами без потреби завантаження додаткових типів@types/node. Планувальник спрацьовує строго один раз на пачку. -
Атомарний зріз черги перед асинхронним викликом:
private dispatch(): void { this.isDispatchScheduled = false; const currentQueue = this.queue; this.queue = []; // ... }
Очищення
this.queueдоawait this.batchFn(...)запобігає втраті даних: будь-які запитиload(), що надійдуть під час очікування відповіді від БД, будуть акумулюватися у нову пачку. -
Строге узгодження індексів при резолві:
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.
Питання: Чому екземпляр
DataLoaderкатегорично не можна реєструвати як стандартний NestJS Singleton (@Injectable()за замовчуванням)? До яких двох критичних наслідків це призведе (безпека/витік даних між користувачами та актуальність даних)?
У фреймворку NestJS усі сервіси та провайдери за замовчуванням створюються в області видимості Singleton — тобто екземпляр класу створюється один раз при старті застосунку і живе в пам'яті протягом усього життєвого циклу процесу Node.js.
Використання DataLoader як Singleton призводить до двох катастрофічних наслідків:
-
DataLoaderкешує завантажені сутності за їхнім ключем (id). Якщо лоадер спільний для всього сервера, дані, які завантажив Користувач$A$ (наприклад, приватні звіти, налаштування або персональні дані), осідають у кеші пам'ятіthis.cache. - Коли Користувач
$B$ робить запит на той самийid, лоадер негайно повертає закешованийPromiseбезпосередньо з оперативної пам'яті. - Запит узагалі не доходить до бази даних, оминаючи всі SQL/NoSQL перевірки прав доступу, фільтри по
tenantIdта механізми безпеки (Row-Level Security). Користувач$B$ отримує доступ до чужої приватної інформації.
- Stale Data: Якщо будь-який запис у базі даних оновився або видалився (через GraphQL Mutation або фоновий воркер), синглтон-лоадер ніколи про це не дізнається. Усі подальші запити до сервера повертатимуть застарілі дані з пам'яті тижнями, аж до перезавантаження процесу.
- Memory Leak: Оскільки внутрішня
Mapне має автоматичної стратегії витіснення (TTL або LRU), кожен новий згенерований ID зберігатиметься в пам'яті сервера назавжди. Це неминуче призведе до вичерпання ліміту пам'яті Node.js (JavaScript heap out of memory) та аварійної зупинки інстансу.
DataLoader завжди повинен мати життєвий цикл Per-Request Scope:
- Створюватися окремо для кожного вхідного HTTP-запиту (найчастіше у GraphQL Context через фабрику або через
@Injectable({ scope: Scope.REQUEST })). - Кешувати дані виключно в межах обробки одного поточного GraphQL-запиту.
- Після відправки HTTP-відповіді клієнту екземпляр лоадера разом із кешем автоматично утилізується Garbage Collector'ом.
Питання: Чому пакетна функція
DataLoaderзобов'язана повертати масив такої ж самої довжини, що й вхідний масив ключів, і зберігати точну відповідність індексів? Що станеться, якщо просто повернутиresult.filter(Boolean)?
Клас DataLoader є універсальним інструментом (BatchLoader<K, V>), який нічого не знає про предметну область та структуру сутностей (він не аналізує наявність полів id, _id, uuid тощо всередині повернених об'єктів).
Єдиним зв'язком між запитаним ключем keys[i] та результатом results[i] є індекс у масиві:
Розглянемо практичний приклад:
- Клієнти запитали трьох користувачів:
keys = ['u1', 'u2', 'u3']. - Користувач
u2був раніше видалений з бази даних. - Запит до БД
find({ _id: { $in: keys } })повернув масив з двох існуючих записів:[ { id: 'u1', name: 'Alice' }, { id: 'u3', name: 'Charlie' } ]
- Якщо пакетна функція поверне цей відфільтрований масив довжиною
$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або падає з помилкою, хоча сутність є в БД!
-
Індекс 0:
Відбувається зсув індексів (Index Shift), що призводить до тихого пошкодження або підміни даних.
Пакетна функція завжди повинна формувати маппінг через словник (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.
Питання: Які типи атак є специфічними саме для GraphQL на відміну від класичного REST API? Чому відключення Introspection у продакшні є гарною практикою, але не вирішує проблему шкідливих циклічних запитів?
У класичному REST API сервер надає жорстко регламентовані ендпоінти з фіксованими схемами відповідей. У GraphQL клієнт самостійно визначає структуру та форму графа даних, що відкриває специфічні вектори DoS-атак:
-
Глибокі циклічні запити (Nested / Circular Queries):
Якщо в схемі є циклічні зв'язки (наприклад,
Category -> Product -> Category -> Product), зловмисник може надіслати запит з екстремальною вкладеністю (наприклад, 100 рівнів). Сервер почне рекурсивно виконувати резолвери, що викличе вичерпання пам'яті та зависання Event Loop. -
Атаки експоненційної складності (Query Complexity / Exponential Fan-out):
Запит може мати невелику глибину, але запитувати зв'язки «один-до-багатьох»:
100 авторів
$\times$ 100 постів$\times$ 100 коментарів =$1,000,000$ вузлів даних в одному запиті. Сервер витратить гігабайти пам'яті на серіалізацію JSON і перевантажить БД. -
Обхід Rate Limiting через аліаси (Alias Batching Attack):
У REST API для перебору 10 000 паролів потрібно зробити 10 000 HTTP-запитів
POST /login, які легко блокуються WAF або Nginx. У GraphQL можна надіслати один-єдиний HTTP POST-запит з 10 000 аліасів однієї мутації:Стандартний IP-обмежувач бачить лише 1 вхідний HTTP-запит і пропускає атаку.mutation BruteForce { a1: login(user: "admin", pass: "pass1") { token } a2: login(user: "admin", pass: "pass2") { token } # ... ще 9998 аліасів }
- Відключення Introspection у продакшні приховує документацію схеми від інструментів на зразок GraphQL Playground чи Postman.
- Проте це лише Security through Obscurity (безпека через невідомість). Якщо зловмисник витягне назви полів із відкритого клієнтського JS-бандла фронтенду або просто підбере кілька базових назв (
users,posts,id), він без проблем сформує шкідливий циклічний запит.
- Query Depth Limiting (наприклад,
graphql-depth-limit): перевіряє AST запиту перед виконанням і відхиляє запити, глибина яких перевищує встановлений поріг (наприклад, максимум 5–7 рівнів). - Query Complexity Analysis (наприклад,
graphql-query-complexity): кожному полю призначається вартість (наприклад, скаляр = 1 бал, список = 10 балів), і запити з вагою понад ліміт (наприклад, 1000) блокуються до старту резолверів. - GqlThrottlerGuard: рейт-лімітинг на рівні GraphQL-контексту.
Питання: Як технологія APQ вирішує проблему розміру мережевого пакета при передачі великих GraphQL-запитів по HTTP і яким чином вона дає змогу використовувати стандартне CDN-кешування (HTTP GET) для запитів на читання?
- Тексти реальних GraphQL-запитів на клієнті (з фрагментами, вибірками десятків полів) можуть сягати 20–100 КБ. Передавати такий обсяг тексту при кожному кліку — це надлишковий мережевий оверхед, що сповільнює мобільні клієнти.
- Оскільки тіло запиту велике, GraphQL традиційно відправляє всі операції через
HTTP POST. Але за стандартом протоколу HTTP запитиPOSTне кешуються браузерами, проміжними проксі та мережами доставки контенту (CDN).
Замість постійної відправки довгого рядка 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 ──►
- Радикальна оптимізація пропускної спроможності (Bandwidth): Після першого збереження запиту клієнт передає лише 64-символьний рядок хешу замість багатокілобайтного тексту запиту.
- Повноцінне використання CDN-кешування (Edge Caching):
Оскільки запити на читання відправляються методом
HTTP GETз детермінованим хешем у query-параметрах (GET /graphql?extensions={"persistedQuery":{...}}), такі запити повністю відповідають специфікації HTTP-кешування. Мережі CDN (Cloudflare, AWS CloudFront, Fastly) можуть кешувати JSON-відповіді відповідно до заголовківCache-Control, віддаючи дані користувачам із найближчого географічного edge-вузла без навантаження на основні сервери застосунку.