Що це. Клієнт-серверна модель означає, що браузер чи мобільний застосунок лише просить, а рішення ухвалює сервер. У реальній системі між ними стоїть цілий ланцюжок: DNS перетворює домен на адресу, CDN віддає статику з найближчого вузла, балансувальник розкидає запити між екземплярами, API gateway перевіряє токен і маршрутизує, сервіс звертається до кешу, бази й інших сервісів. Кожна ланка має власні таймаути, заголовки, ліміти й власний спосіб зламатись.
What it is. The client-server model means the browser or mobile app only asks, while the server makes decisions. In a real system there is a whole chain between them: DNS turns a domain into an address, a CDN serves static files from the nearest edge, a load balancer spreads requests across instances, an API gateway checks the token and routes, and the service calls a cache, a database and other services. Each link has its own timeouts, headers, limits and its own way of breaking.
Навіщо QA це знати. Коли тест бачить 502 чи повільну сторінку, симптом один, а причин десяток. Тестувальник, який тримає в голові цю схему, одразу питає: помилку повернув gateway чи сам сервіс? Відповідь прийшла з кешу CDN чи з origin? Це звужує пошук і робить баг-репорт корисним для розробника, а не просто «сайт не працює».
Why QA needs it. When a test sees a 502 or a slow page, there is one symptom and a dozen causes. A tester who keeps this map in mind immediately asks: did the gateway return the error or the service itself? Did the response come from the CDN cache or from origin? That narrows the search and turns a bug report into something useful for a developer instead of "the site is broken".
Як тестувати. Для кожної ланки є своя перевірка. CDN: заголовки Cache-Control, Age, X-Cache і те, що персональні сторінки не кешуються. Балансувальник: запити розходяться по екземплярах, а вимкнення одного не рве сесії. Gateway: без токена 401, з чужим токеном 403, завеликий body дає 413. Сервіс: бізнес-логіка і коректні коди. Кеш і база: дані після зміни не застарілі. Корисна звичка — додавати в кожен запит власний X-Request-Id і шукати його в логах кожної ланки.
How to test. Each link has its own check. CDN: the Cache-Control, Age and X-Cache headers, and personal pages are not cached. Load balancer: requests spread across instances, and taking one down does not break sessions. Gateway: no token gives 401, someone else's token gives 403, an oversized body gives 413. Service: business logic and correct status codes. Cache and database: data is not stale after a change. A useful habit is to send your own X-Request-Id with every request and look for it in the logs of every hop.
Підводні камені. Тести, які б'ють напряму в сервіс на внутрішньому порту, оминають gateway, CDN і балансувальник, тож не бачать проблем з CORS, лімітами розміру, стисненням чи кешуванням. Частина E2E-перевірок має йти через ту саму точку входу, що й користувач. Друга пастка — вважати, що 5xx завжди означає баг у коді сервісу: часто це таймаут проксі, який коротший за час обробки.
Pitfalls. Tests that hit a service directly on an internal port bypass the gateway, CDN and load balancer, so they miss problems with CORS, size limits, compression or caching. Some E2E checks must go through the same entry point as the user. The second trap is assuming a 5xx always means a bug in the service code: often it is a proxy timeout shorter than the processing time.
Уточнення. Інтерв'юер може спитати, чим 502 відрізняється від 504. 502 Bad Gateway — проксі отримав від upstream некоректну відповідь або з'єднання обірвалось; 504 Gateway Timeout — upstream не відповів вчасно. Обидва коди генерує проміжна ланка, а не сам сервіс, і саме це варто написати в баг-репорті.
Follow-up. The interviewer may ask how 502 differs from 504. 502 Bad Gateway means the proxy got an invalid response from upstream or the connection dropped; 504 Gateway Timeout means upstream did not answer in time. Both codes are produced by an intermediate hop, not by the service itself, and that is exactly what belongs in the bug report.
Браузер / мобільний клієнт
| DNS: домен -> IP
v
CDN (edge) кеш статики, Cache-Control, Age, X-Cache
| cache miss
v
Load balancer розподіл, health checks, draining
|
v
API gateway 401/403, 413, 429, маршрутизація, CORS
|
v
Order service ---> Redis (кеш) застарілі дані?
| ---> Payment service таймаут, ретрай, дубль?
v
PostgreSQL (primary + репліка) лаг репліки?
Browser / mobile client
| DNS: domain -> IP
v
CDN (edge) static cache, Cache-Control, Age, X-Cache
| cache miss
v
Load balancer distribution, health checks, draining
|
v
API gateway 401/403, 413, 429, routing, CORS
|
v
Order service ---> Redis (cache) stale data?
| ---> Payment service timeout, retry, duplicate?
v
PostgreSQL (primary + replica) replica lag?
curl -s -D - -o /dev/null https://shop.example.com/api/orders/42 \
-H "Authorization: Bearer $TOKEN" \
-H "X-Request-Id: qa-2026-09-29-001"
# далі шукаємо qa-2026-09-29-001 у логах gateway, сервісу і трасуванні
curl -s -D - -o /dev/null https://shop.example.com/api/orders/42 \
-H "Authorization: Bearer $TOKEN" \
-H "X-Request-Id: qa-2026-09-29-001"
# then search for qa-2026-09-29-001 in gateway logs, service logs and traces
-D - друкує заголовки відповіді, -o /dev/null викидає тіло, а власний X-Request-Id дає нитку, за якою запит знаходиться в логах усіх ланок.-D - prints the response headers, -o /dev/null discards the body, and your own X-Request-Id gives a thread for finding the request in the logs of every hop.Що це. Моноліт — один застосунок, який збирається й деплоїться цілком: модулі викликають одне одного звичайними функціями в межах процесу і зазвичай ділять одну базу. Мікросервісна архітектура — це набір невеликих незалежних сервісів, кожен зі своєю базою й власним циклом релізів, які спілкуються через мережу: HTTP, gRPC або черги. Жоден зі стилів не кращий сам по собі: моноліт простіший у розробці й налагодженні, мікросервіси дають незалежні релізи та масштабування окремих частин ціною розподіленої складності.
What it is. A monolith is one application built and deployed as a whole: modules call each other through plain in-process functions and usually share one database. A microservice architecture is a set of small independent services, each with its own database and release cycle, talking over the network: HTTP, gRPC or queues. Neither style is better by itself: a monolith is simpler to build and debug, while microservices give independent releases and per-part scaling at the cost of distributed complexity.
Навіщо QA це розрізняти. У моноліті виклик між модулями не може «загубитись у мережі», транзакція охоплює всі таблиці, а один E2E-прогін перевіряє майже все. У мікросервісах кожен міжсервісний виклик може впасти, повторитись чи відповісти із запізненням, дані між сервісами узгоджуються з затримкою, а релізи йдуть незалежно — отже, вчорашня зелена інтеграція сьогодні може зламатись через чужий деплой.
Why QA cares. In a monolith a call between modules cannot "get lost in the network", a transaction spans every table, and one E2E run checks almost everything. In microservices every cross-service call can fail, repeat or answer late, data between services converges with a delay, and releases happen independently, so an integration that was green yesterday can break today because of someone else's deploy.
Як тестувати. Для моноліту піраміда класична: багато юніт-тестів, помірно інтеграційних з реальною базою, кілька E2E. Для мікросервісів у середині піраміди з'являються нові шари: компонентні тести одного сервісу з замоканими сусідами, контрактні тести на кожну пару споживач-постачальник і тести відмов (таймаут, 503, дубль повідомлення). E2E-сценаріїв стає менше, а не більше: вони дорогі, повільні й нестабільні, тож лишаються тільки ключові бізнес-потоки.
How to test. For a monolith the pyramid is classic: many unit tests, a moderate number of integration tests against a real database, a few E2E. For microservices new layers appear in the middle: component tests of one service with its neighbours mocked, contract tests for every consumer-provider pair, and failure tests (timeout, 503, duplicate message). The number of E2E scenarios goes down, not up: they are expensive, slow and flaky, so only the key business flows stay.
Підводні камені. Найчастіша помилка команди при переході на мікросервіси — переносити стару стратегію як є і покривати все наскрізними тестами на спільному стейджингу. Такий набір падає через чужі деплої, і ніхто не довіряє червоному кольору. Друга пастка — «розподілений моноліт»: сервіси формально окремі, але релізяться лише разом і ділять базу, тож усі мінуси мережі є, а незалежності немає.
Pitfalls. The most common mistake when moving to microservices is to carry the old strategy over unchanged and cover everything with end-to-end tests on a shared staging. Such a suite fails because of other teams' deploys, and nobody trusts the red colour any more. The second trap is a "distributed monolith": services are formally separate but only release together and share a database, so you get all the network downsides without the independence.
Уточнення. Можуть спитати, коли моноліт — правильний вибір. Для невеликої команди й нового продукту, де межі домену ще не устоялись, модульний моноліт часто дешевший. Як тестувальник я додам, що чіткі межі модулів у моноліті дозволяють згодом виділити сервіс, і тести на рівні модуля переживуть цей переїзд.
Follow-up. You may be asked when a monolith is the right choice. For a small team and a new product whose domain boundaries have not settled, a modular monolith is often cheaper. As a tester I would add that clear module boundaries inside a monolith make it possible to extract a service later, and module-level tests survive that move.
Шар | Моноліт | Мікросервіси
-----------------+--------------------------+---------------------------
Юніт | багато, логіка модулів | багато, у кожному сервісі
Інтеграційні | застосунок + реальна БД | сервіс + його БД
| | (Testcontainers)
Компонентні | рідко потрібні | сервіс + моки сусідів
Контрактні | не потрібні | кожна пара споживач/
| | постачальник (Pact)
Відмови мережі | майже немає | таймаути, 503, дублі
E2E | кілька десятків | лише ключові потоки
Де ламається | у коді | на межах між сервісами
Layer | Monolith | Microservices
-----------------+--------------------------+---------------------------
Unit | many, module logic | many, in every service
Integration | app + real DB | service + its own DB
| | (Testcontainers)
Component | rarely needed | service + mocked peers
Contract | not needed | every consumer/provider
| | pair (Pact)
Network failures | almost none | timeouts, 503, duplicates
E2E | a few dozen | key flows only
Where it breaks | in the code | at service boundaries
Що це. REST — стиль поверх HTTP: ресурси за URL, дієслова GET, POST, PUT, DELETE, зазвичай JSON і стандартні коди статусу. gRPC — RPC-фреймворк на HTTP/2 з бінарним Protobuf: контракт описано в .proto-файлі, клієнт і сервер генеруються з нього, підтримуються стріми. GraphQL — мова запитів з одним ендпоінтом, де клієнт сам перелічує потрібні поля і отримує рівно їх, без over-fetching і under-fetching.
What it is. REST is a style on top of HTTP: resources at URLs, the GET, POST, PUT and DELETE verbs, usually JSON and standard status codes. gRPC is an RPC framework on HTTP/2 with binary Protobuf: the contract lives in a .proto file, client and server are generated from it, and streaming is supported. GraphQL is a query language with a single endpoint where the client lists the fields it needs and gets exactly those, avoiding over-fetching and under-fetching.
Навіщо розуміти різницю. Від протоколу залежить, де шукати помилку і чим її ловити. У REST сигнал про збій — код статусу. У gRPC — власні коди на кшталт NOT_FOUND, UNAVAILABLE, DEADLINE_EXCEEDED, а тіло не прочитаєш очима без інструмента. У GraphQL відповідь з помилкою часто приходить зі статусом 200, а сама помилка лежить у масиві errors поруч із частково заповненим data.
Why the difference matters. The protocol decides where to look for an error and what tool catches it. In REST the failure signal is the status code. In gRPC it is its own codes such as NOT_FOUND, UNAVAILABLE and DEADLINE_EXCEEDED, and you cannot read the body by eye without a tool. In GraphQL an error response often arrives with status 200, while the error itself sits in the errors array next to a partially filled data.
Як тестувати. REST: коди, заголовки, валідація схеми відповіді за OpenAPI, семантика методів. gRPC: grpcurl або Postman для ручних викликів, перевірка статус-кодів і дедлайнів, а головне — зворотна сумісність .proto: не можна змінювати номер поля чи його тип, видалене поле позначають reserved. GraphQL: асерт на відсутність errors, а не лише на 200; ліміти глибини та складності запиту; права доступу на рівні окремих полів; N+1 у резолверах, коли список із сотні елементів породжує сотню запитів до бази.
How to test. REST: status codes, headers, response schema validation against OpenAPI, method semantics. gRPC: grpcurl or Postman for manual calls, checks on status codes and deadlines, and above all backward compatibility of the .proto: you must not change a field number or its type, and a removed field is marked reserved. GraphQL: assert that errors is absent, not just that the status is 200; query depth and complexity limits; field-level authorisation; N+1 in resolvers, when a list of a hundred items triggers a hundred database queries.
Підводні камені. Автотест GraphQL, що перевіряє тільки статус 200, пропустить половину збоїв. У gRPC легко забути про дедлайн: клієнт без нього чекатиме вічно. А для внутрішніх gRPC-сервісів ще й бракує звичного логування тіла запиту в проксі, тому варто домовитись із розробниками про reflection на тестових середовищах.
Pitfalls. A GraphQL test that only checks for status 200 misses half the failures. In gRPC it is easy to forget the deadline: a client without one waits forever. Internal gRPC services also lack the familiar request-body logging in proxies, so it is worth agreeing with developers to enable reflection on test environments.
Уточнення. Якщо спитають, що обрати, я відповім компромісом: REST для публічних API через простоту й кешування, gRPC для швидкого внутрішнього обміну між сервісами, GraphQL для фронтенду, якому потрібні дані з кількох джерел одним запитом.
Follow-up. If asked which to pick, I answer with a trade-off: REST for public APIs because of simplicity and HTTP caching, gRPC for fast internal traffic between services, GraphQL for a frontend that needs data from several sources in one request.
# gRPC: виклик методу з JSON-тілом і дедлайном 2 с
grpcurl -plaintext -max-time 2 -d '{"id": "42"}' \
localhost:50051 orders.v1.OrderService/GetOrder
# GraphQL: статус 200 ще не означає успіх
curl -s https://api.example.com/graphql \
-H "Content-Type: application/json" \
-d '{"query": "{ order(id: 42) { id status } }"}' \
| jq -e '.errors == null' # код виходу 1, якщо є errors
# gRPC: call a method with a JSON body and a 2 s deadline
grpcurl -plaintext -max-time 2 -d '{"id": "42"}' \
localhost:50051 orders.v1.OrderService/GetOrder
# GraphQL: status 200 does not yet mean success
curl -s https://api.example.com/graphql \
-H "Content-Type: application/json" \
-d '{"query": "{ order(id: 42) { id status } }"}' \
| jq -e '.errors == null' # exit code 1 if errors exist
grpcurl приймає адресу і повне ім'я методу у форматі пакет.Сервіс/Метод; -plaintext вимикає TLS для локального стенда, -d передає тіло як JSON, яке інструмент перекладає в Protobuf (через reflection або .proto-файли). У GraphQL-частині jq -e повертає ненульовий код виходу, якщо вираз хибний, тому такий рядок одразу валить CI-крок.grpcurl takes an address and a full method name in the package.Service/Method form; -plaintext turns off TLS for a local stand, and -d passes the body as JSON, which the tool translates into Protobuf (via reflection or .proto files). In the GraphQL part, jq -e returns a non-zero exit code when the expression is false, so the line fails a CI step straight away.Що це. API gateway — єдина точка входу для зовнішніх клієнтів перед набором сервісів. Він маршрутизує запит за шляхом чи хостом до потрібного сервісу, перевіряє автентифікацію (JWT, API-ключ), застосовує ліміти запитів і розміру тіла, обробляє CORS, термінує TLS, інколи агрегує відповіді кількох сервісів або перетворює протоколи. Приклади — Kong, AWS API Gateway, Envoy чи NGINX у ролі шлюзу.
What it is. An API gateway is the single entry point for external clients in front of a set of services. It routes a request by path or host to the right service, checks authentication (JWT, API key), applies request-rate and body-size limits, handles CORS, terminates TLS, and sometimes aggregates responses from several services or translates protocols. Examples are Kong, AWS API Gateway, and Envoy or NGINX acting as a gateway.
Навіщо окремі перевірки. Gateway — це конфігурація, а конфігурацію теж ламають. Новий маршрут без правила автентифікації відкриває приватний ендпоінт усім; неправильний префікс шляху віддає 404 лише в продакшені; ліміт тіла в 1 МБ ламає завантаження аватарів. Ці баги не видно в тестах, які звертаються до сервісу напряму, бо вони живуть між клієнтом і сервісом.
Why separate checks. A gateway is configuration, and configuration breaks too. A new route without an authentication rule opens a private endpoint to everyone; a wrong path prefix returns 404 only in production; a 1 MB body limit breaks avatar uploads. These bugs are invisible to tests that call the service directly, because they live between the client and the service.
Як тестувати. Я складаю матрицю негативних запитів через публічну адресу: без токена очікую 401, з токеном без потрібного скоупу — 403, з простроченим токеном — 401, з тілом понад ліміт — 413, на неіснуючий маршрут — 404 без стек-трейсу, серією запитів понад квоту — 429. Окремо перевіряю CORS-preflight OPTIONS з чужого та дозволеного Origin, що gateway не пропускає до сервісу службові заголовки від клієнта (наприклад, підроблений X-User-Id), і що таймаут шлюзу більший за нормальний час відповіді сервісу.
How to test. I build a matrix of negative requests through the public address: no token expects 401, a token without the required scope 403, an expired token 401, a body over the limit 413, an unknown route 404 without a stack trace, a burst over the quota 429. Separately I check the CORS preflight OPTIONS from a foreign and an allowed Origin, that the gateway does not pass internal headers supplied by the client (for example a forged X-User-Id) to the service, and that the gateway timeout is longer than the service's normal response time.
Підводні камені. Коли gateway сам повертає помилку, тіло відповіді має його формат, а не формат сервісу: фронтенд, що парсить лише «рідні» помилки, може впасти. Ще одна пастка — порядок плагінів: якщо ліміт запитів спрацьовує раніше за автентифікацію, анонім вичерпує квоту справжнього користувача.
Pitfalls. When the gateway returns an error itself, the body has the gateway's format, not the service's: a frontend that only parses "native" errors may crash. Another trap is plugin order: if rate limiting runs before authentication and is keyed by IP or a claimed identity, an anonymous caller can burn a real user's quota.
Уточнення. Чим gateway відрізняється від балансувальника? Балансувальник розподіляє трафік між однаковими екземплярами одного сервісу, а gateway розуміє API: маршрути, токени, квоти на клієнта. На практиці вони часто стоять послідовно.
Follow-up. How does a gateway differ from a load balancer? A load balancer spreads traffic across identical instances of one service, while a gateway understands the API: routes, tokens, per-client quotas. In practice they often sit one after the other.
API=https://api.example.com/v1
code() { curl -s -o /dev/null -w "%{http_code}" "$@"; }
code "$API/orders" # чекаємо 401
code -H "Authorization: Bearer $READ_ONLY" \
-X POST "$API/orders" # чекаємо 403
code -H "Authorization: Bearer $TOKEN" \
--data-binary @big-2mb.json "$API/orders" # чекаємо 413
code -H "Authorization: Bearer $TOKEN" "$API/nope" # чекаємо 404
code -X OPTIONS -H "Origin: https://evil.example" \
-H "Access-Control-Request-Method: POST" \
-D - "$API/orders" # без Access-Control-Allow-Origin
API=https://api.example.com/v1
code() { curl -s -o /dev/null -w "%{http_code}" "$@"; }
code "$API/orders" # expect 401
code -H "Authorization: Bearer $READ_ONLY" \
-X POST "$API/orders" # expect 403
code -H "Authorization: Bearer $TOKEN" \
--data-binary @big-2mb.json "$API/orders" # expect 413
code -H "Authorization: Bearer $TOKEN" "$API/nope" # expect 404
code -X OPTIONS -H "Origin: https://evil.example" \
-H "Access-Control-Request-Method: POST" \
-D - "$API/orders" # no Access-Control-Allow-Origin
code загортає curl: -o /dev/null викидає тіло, а -w "%{http_code}" друкує лише код статусу, тож кожен рядок дає одне число для асерту. --data-binary @файл шле файл як є, що зручно для перевірки ліміту розміру. Останній запит імітує CORS-preflight: браузер спершу надсилає OPTIONS з Origin і Access-Control-Request-Method, і для чужого домену дозвільного заголовка у відповіді бути не повинно.code function wraps curl: -o /dev/null discards the body and -w "%{http_code}" prints only the status code, so every line yields one number to assert on. --data-binary @file sends a file as is, handy for checking the size limit. The last request imitates a CORS preflight: a browser first sends OPTIONS with Origin and Access-Control-Request-Method, and for a foreign domain the response must not contain the allow header.Що це. OpenAPI (колишній Swagger) — машиночитна специфікація REST API у YAML чи JSON: шляхи, методи, параметри, схеми тіл запиту й відповіді, коди статусу, схеми автентифікації. З неї генерують документацію, клієнтів і серверні заготовки. Версіонування API — спосіб випускати несумісні зміни, не ламаючи наявних клієнтів: версія в шляху (/v2/orders), у заголовку чи в медіатипі.
What it is. OpenAPI (formerly Swagger) is a machine-readable specification of a REST API in YAML or JSON: paths, methods, parameters, request and response body schemas, status codes and authentication schemes. Documentation, clients and server stubs are generated from it. API versioning is a way to ship incompatible changes without breaking existing clients: a version in the path (/v2/orders), in a header or in the media type.
Навіщо. Мобільний застосунок трирічної давнини досі в когось на телефоні й досі кличе API. Якщо бекенд перейменує поле чи зробить його обов'язковим, старий клієнт зламається без жодного нового релізу з його боку. Специфікація дає одне джерело правди, з яким можна автоматично порівняти і нову версію API, і реальні відповіді сервера.
Why. A mobile app from three years ago is still on someone's phone and still calls the API. If the backend renames a field or makes it required, the old client breaks without any new release on its side. The specification provides a single source of truth that both the new API version and the server's real responses can be compared against automatically.
Як тестувати. Перше — валідація відповідей за схемою в автотестах: кожна відповідь перевіряється на відповідність OpenAPI, тож «тихе» нове поле з іншим типом одразу червоне. Друге — diff специфікацій у CI: інструмент на кшталт oasdiff порівнює стару й нову версії і валить збірку на зламах. Несумісні зміни: видалення поля чи ендпоінта, зміна типу, новий обов'язковий параметр запиту, нове значення enum у відповіді для строгих клієнтів. Сумісні: нове необов'язкове поле, новий ендпоінт. Третє — тести старих версій клієнтів проти нового бекенду.
How to test. First, schema validation of responses in automated tests: every response is checked against OpenAPI, so a "quiet" new field with a different type turns red at once. Second, a spec diff in CI: a tool such as oasdiff compares the old and new versions and fails the build on breaks. Breaking changes: removing a field or endpoint, changing a type, a new required request parameter, a new enum value in a response for strict clients. Compatible: a new optional field, a new endpoint. Third, tests of old client versions against the new backend.
Підводні камені. Специфікація, яку пишуть руками окремо від коду, швидко розходиться з реальністю, і тоді всі перевірки порівнюють дві вигадки. Краще генерувати її з коду або валідувати трафік за нею. Також версія в URL не рятує, якщо v1 і v2 ділять одну модель і зміна в ній протікає в обидві.
Pitfalls. A spec written by hand separately from the code quickly drifts from reality, and then every check compares two fictions. It is better to generate it from code or validate traffic against it. Also, a version in the URL does not help if v1 and v2 share one model and a change to it leaks into both.
Уточнення. Як довго підтримувати стару версію? Стільки, скільки нею користуються: я б попросив метрику викликів за версіями і заголовок Deprecation або Sunset у відповідях, щоб клієнти дізнавались про дату вимкнення заздалегідь.
Follow-up. How long should an old version be supported? As long as it is used: I would ask for a per-version call metric and a Deprecation or Sunset header in responses so clients learn the shutdown date in advance.
# падає, якщо в новій специфікації є несумісні зміни
oasdiff breaking api/openapi-main.yaml api/openapi.yaml \
--fail-on ERR
# ламає клієнтів:
# видалено поле Order.discount з відповіді
# тип Order.total: number -> string
# новий обов'язковий query-параметр currency
# сумісно:
# нове необов'язкове поле Order.note
# новий ендпоінт GET /v1/orders/{id}/history
# fails if the new spec contains breaking changes
oasdiff breaking api/openapi-main.yaml api/openapi.yaml \
--fail-on ERR
# breaks clients:
# field Order.discount removed from the response
# type of Order.total: number -> string
# new required query parameter currency
# compatible:
# new optional field Order.note
# new endpoint GET /v1/orders/{id}/history
oasdiff breaking приймає базову та нову специфікації й друкує лише зміни, що ламають сумісність, з рівнями ERR і WARN; --fail-on ERR повертає ненульовий код виходу, тож CI-крок падає. Базову версію зазвичай беруть із main-гілки, а нову — з пул-реквесту. Коментарі нижче показують, які зміни інструмент позначить як несумісні, а які пропустить.oasdiff breaking takes the base and the new spec and prints only compatibility-breaking changes, with ERR and WARN levels; --fail-on ERR returns a non-zero exit code so the CI step fails. The base version is usually taken from the main branch and the new one from the pull request. The comments below show which changes the tool flags as breaking and which it lets through.Що це. Контейнер (Docker) пакує застосунок разом із залежностями в образ, тож сервіс однаково стартує на ноутбуці, у CI і в кластері. Kubernetes — оркестратор: запускає контейнери в подах, перезапускає впалі, масштабує кількість реплік і розкочує нові версії. Health check — ендпоінт чи команда, за якою платформа вирішує, чи живий екземпляр. Service discovery — механізм, що дозволяє знайти сервіс за ім'ям (у Kubernetes це Service і DNS на кшталт orders.shop.svc), а не за жорстко прописаною IP-адресою, яка змінюється з кожним перезапуском пода.
What it is. A container (Docker) packs an application together with its dependencies into an image, so a service starts the same way on a laptop, in CI and in a cluster. Kubernetes is an orchestrator: it runs containers in pods, restarts crashed ones, scales the number of replicas and rolls out new versions. A health check is an endpoint or command the platform uses to decide whether an instance is alive. Service discovery lets you find a service by name (in Kubernetes that is a Service and DNS such as orders.shop.svc) rather than by a hard-coded IP that changes with every pod restart.
Навіщо QA. Контейнери дають тестувальнику відтворюване середовище: той самий образ, що пройшов тести, їде в продакшн. А проби Kubernetes напряму впливають на доступність. У Kubernetes їх три: liveness — чи процес не завис (провал означає перезапуск), readiness — чи готовий приймати трафік (провал означає прибрати з балансування без перезапуску), startup — чи закінчився повільний старт.
Why QA cares. Containers give a tester a reproducible environment: the same image that passed the tests goes to production. And Kubernetes probes directly affect availability. There are three: liveness checks that the process is not stuck (failure means restart), readiness checks that it can accept traffic (failure means removal from load balancing without a restart), and startup checks that a slow start has finished.
Як тестувати. Перевіряю, що readiness повертає помилку, поки сервіс прогріває кеш чи не має з'єднання з базою, і що під час розкатки нової версії немає 5xx. Перевіряю, що liveness не залежить від бази: інакше короткий збій бази перезапустить усі поди разом. Локально піднімаю залежності через docker-compose з healthcheck, щоб тести не стартували раніше, ніж база готова. Для discovery — що сервіс знаходить сусіда за ім'ям після перезапуску і переїзду подів.
How to test. I check that readiness returns an error while the service is warming its cache or has no database connection, and that a rollout of a new version produces no 5xx. I check that liveness does not depend on the database: otherwise a short database outage restarts every pod at once. Locally I start dependencies with docker-compose and healthchecks, so the tests do not start before the database is ready. For discovery, I check that the service finds its neighbour by name after pods restart and move.
Підводні камені. Класичний флакі-тест у CI — depends_on без умови: контейнер бази запущено, але Postgres ще не приймає з'єднань. Друга пастка — однаковий ендпоінт для liveness і readiness з перевіркою всіх залежностей, що перетворює збій одного сусіда на каскадні перезапуски.
Pitfalls. A classic flaky CI test is depends_on without a condition: the database container is running, but Postgres is not yet accepting connections. The second trap is one endpoint for both liveness and readiness that checks every dependency, which turns one neighbour's failure into cascading restarts.
Уточнення. Що буде, якщо readiness завжди повертає 200? Під отримає трафік до того, як готовий, і перші користувачі після кожного деплою бачитимуть помилки. Це легко відтворити тестом, що шле запити безперервно під час розкатки.
Follow-up. What happens if readiness always returns 200? The pod gets traffic before it is ready, and the first users after every deploy see errors. That is easy to reproduce with a test that sends requests continuously during a rollout.
services:
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: test
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 2s
retries: 15
orders:
build: .
depends_on:
db:
condition: service_healthy # не просто запущено
services:
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: test
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 2s
retries: 15
orders:
build: .
depends_on:
db:
condition: service_healthy # not just started
livenessProbe: # процес живий? ні -> перезапуск
httpGet: { path: /health/live, port: 8080 }
periodSeconds: 10
readinessProbe: # готовий до трафіку? ні -> без трафіку
httpGet: { path: /health/ready, port: 8080 }
periodSeconds: 5
startupProbe: # повільний старт завершено?
httpGet: { path: /health/live, port: 8080 }
failureThreshold: 30
periodSeconds: 2
livenessProbe: # process alive? no -> restart
httpGet: { path: /health/live, port: 8080 }
periodSeconds: 10
readinessProbe: # ready for traffic? no -> no traffic
httpGet: { path: /health/ready, port: 8080 }
periodSeconds: 5
startupProbe: # has the slow start finished?
httpGet: { path: /health/live, port: 8080 }
failureThreshold: 30
periodSeconds: 2
healthcheck.test виконується всередині контейнера, а condition: service_healthy змушує сервіс чекати, доки перевірка пройде; без умови depends_on гарантує лише порядок запуску. У Kubernetes кожна проба має свій наслідок провалу: liveness перезапускає контейнер, readiness прибирає под з ендпоінтів Service, а startup відкладає обидві інші, доки старт не завершиться (тут до 30 × 2 = 60 секунд).healthcheck.test runs inside the container, and condition: service_healthy makes the service wait until the check passes; without the condition depends_on only guarantees start order. In Kubernetes each probe has its own failure consequence: liveness restarts the container, readiness removes the pod from the Service endpoints, and startup postpones the other two until the start is over (here up to 30 × 2 = 60 seconds).Що це. Service mesh (Istio, Linkerd) — інфраструктурний шар, що перехоплює весь трафік між сервісами через проксі поруч із кожним подом (sidecar, найчастіше Envoy) або через спільні проксі на вузлі. Mesh бере на себе mTLS між сервісами, ретраї, таймаути, розподіл трафіку між версіями, метрики й трасування — без змін у коді застосунку. Керують цим декларативними ресурсами на кшталт VirtualService і DestinationRule.
What it is. A service mesh (Istio, Linkerd) is an infrastructure layer that intercepts all traffic between services through a proxy next to every pod (a sidecar, usually Envoy) or through shared per-node proxies. The mesh takes over mTLS between services, retries, timeouts, traffic splitting between versions, metrics and tracing, with no change to the application code. It is driven by declarative resources such as VirtualService and DestinationRule.
Навіщо це тестувальнику. По-перше, частина поведінки, яку раніше перевіряли в коді, тепер живе в YAML: ретрай чи таймаут може бути і в клієнтській бібліотеці, і в mesh, і вони множаться. По-друге, mesh дає вбудовану ін'єкцію збоїв: можна додати затримку чи повернути 503 для частки запитів або лише для запитів із тестовим заголовком — ідеальний інструмент для тестів стійкості без окремого chaos-фреймворку.
Why a tester cares. First, part of the behaviour that used to be tested in code now lives in YAML: a retry or timeout can exist both in the client library and in the mesh, and they multiply. Second, the mesh has built-in fault injection: you can add delay or return 503 for a share of requests or only for requests carrying a test header, which is an ideal tool for resilience tests without a separate chaos framework.
Як тестувати. Конфігурацію mesh я вважаю кодом і тестую її так само. Перевіряю, що з ін'єкованою затримкою 3 секунди викликач спрацьовує за своїм таймаутом і показує деградовану відповідь, а не висить. Перевіряю, що з увімкненим STRICT mTLS под без sidecar не може достукатись до сервісу. При канарці 90/10 рахую, що частка запитів на нову версію справді близька до 10%. І дивлюсь сумарну кількість спроб: ретрай у коді × ретрай у mesh.
How to test. I treat mesh configuration as code and test it the same way. I check that with an injected 3-second delay the caller fires its own timeout and returns a degraded response instead of hanging. I check that with STRICT mTLS enabled a pod without a sidecar cannot reach the service. With a 90/10 canary split I count that the share of requests reaching the new version really is close to 10%. And I look at the total number of attempts: retries in code times retries in the mesh.
Підводні камені. Множення ретраїв: якщо на кожному з трьох рівнів ланцюжка стоїть по 3 спроби, найглибший сервіс отримує до 3 × 3 × 3 = 27 запитів замість одного, а якщо ретраїть ще й код поверх mesh, число росте далі — під час збою це добиває сервіс. Друга пастка — mesh додає затримку й окремий компонент, який сам може впасти: тести продуктивності без sidecar дають оптимістичні цифри. Третя — ін'єкція збоїв без фільтра за заголовком на спільному стенді ламає роботу всім командам.
Pitfalls. Retry multiplication: with 3 attempts at each of three levels of a call chain, the deepest service receives up to 3 × 3 × 3 = 27 requests instead of one, and if application code retries on top of the mesh the number grows further — during an outage that finishes the service off. The second trap is that the mesh adds latency and a component that can fail itself: performance tests without sidecars give optimistic numbers. The third is fault injection without a header filter on a shared stand, which breaks everyone else's work.
Уточнення. Чи потрібен mesh кожній системі? Ні: для десятка сервісів він часто додає більше операційної складності, ніж користі. Але якщо він є, тестова стратегія має явно враховувати, де саме налаштовані таймаути й ретраї — в коді чи в mesh.
Follow-up. Does every system need a mesh? No: for a dozen services it often adds more operational complexity than value. But if there is one, the test strategy must state explicitly where timeouts and retries are configured — in code or in the mesh.
apiVersion: networking.istio.io/v1
kind: VirtualService
metadata:
name: payments
spec:
hosts: ["payments"]
http:
- match:
- headers:
x-qa-fault: # збій лише для тестового трафіку
exact: "delay"
fault:
delay:
percentage: { value: 100 }
fixedDelay: 3s
route:
- destination: { host: payments }
- route: # решта трафіку без змін
- destination: { host: payments }
apiVersion: networking.istio.io/v1
kind: VirtualService
metadata:
name: payments
spec:
hosts: ["payments"]
http:
- match:
- headers:
x-qa-fault: # fault only for test traffic
exact: "delay"
fault:
delay:
percentage: { value: 100 }
fixedDelay: 3s
route:
- destination: { host: payments }
- route: # all other traffic unchanged
- destination: { host: payments }
http перевіряються згори вниз, перше збіжне виграє. Перше правило спрацьовує лише для запитів із заголовком x-qa-fault: delay і додає 3 секунди затримки до 100% таких запитів; друге правило без match обслуговує решту трафіку як завжди. Замість delay можна вказати abort з httpStatus: 503. Щоб заголовок дійшов до глибших сервісів, застосунок має пропагувати його далі.http are evaluated top to bottom and the first match wins. The first rule fires only for requests with the x-qa-fault: delay header and adds a 3-second delay to 100% of them; the second rule, with no match, serves all other traffic as usual. Instead of delay you can set abort with httpStatus: 503. For the header to reach deeper services, the application has to propagate it.Що це. Системна безпека — це не вразливості в окремій формі, а питання «хто кому довіряє». Межа довіри проходить там, де дані чи запит переходять із менш захищеної зони в більш захищену: інтернет → gateway, gateway → сервіси, сервіс → база, одна організація-клієнт → дані іншої. Ключові механізми: автентифікація користувача на вході, ідентичність сервісів між собою (mTLS, токени з правильною аудиторією), авторизація на кожному сервісі, ізоляція тенантів і керування секретами.
What it is. System-level security is not about a flaw in one form but about "who trusts whom". A trust boundary runs wherever data or a request moves from a less protected zone into a more protected one: internet → gateway, gateway → services, service → database, one customer organisation → another's data. The key mechanisms are user authentication at the edge, service identity between services (mTLS, tokens with the right audience), authorisation in every service, tenant isolation and secret management.
Навіщо. Поширена архітектурна помилка — «перевіряємо токен лише на gateway, усередині всі свої». Тоді будь-хто, хто дістався внутрішньої мережі через скомпрометований под, забутий відкритий порт чи SSRF у сусідньому сервісі, отримує повний доступ. Друга типова діра — сервіс перевіряє, що користувач автентифікований, але не перевіряє, що замовлення з ID 42 належить саме його організації.
Why. A common architectural mistake is "we check the token only at the gateway, everyone inside is trusted". Then anyone who reaches the internal network through a compromised pod, a forgotten open port or SSRF in a neighbouring service gets full access. The second typical hole is a service that checks the user is authenticated but not that order 42 belongs to that user's organisation.
Як тестувати. Я малюю схему з межами довіри і для кожної межі пишу негативні тести. Прямий виклик внутрішнього сервісу в обхід gateway без токена — очікую 401 або відмову з'єднання. Токен, виданий для іншого сервісу (чужа aud), — 401. Користувач тенанта A запитує ресурс тенанта B за відомим ID — 404 або 403, і так на кожному сервісі, включно з експортами, вебхуками й пошуком. Перевіряю, що службові ендпоінти на кшталт /actuator/env чи /metrics не доступні ззовні, а секрети не потрапляють у логи, трейси й повідомлення про помилки.
How to test. I draw a diagram with trust boundaries and write negative tests for each one. A direct call to an internal service bypassing the gateway without a token: I expect 401 or a refused connection. A token issued for another service (a foreign aud): 401. A user of tenant A requests a resource of tenant B by a known ID: 404 or 403, and so on in every service, including exports, webhooks and search. I check that internal endpoints like /actuator/env or /metrics are not reachable from outside and that secrets do not end up in logs, traces or error messages.
Підводні камені. Ізоляцію тенантів часто перевіряють лише через UI, де чужих ID просто не видно, а асинхронні шляхи — черги, пакетні експорти, кеш із ключем без tenant id — лишаються неперевіреними. Ще одна пастка — тестові середовища з вимкненим mTLS «для зручності»: тоді ці тести нічого не доводять про продакшн.
Pitfalls. Tenant isolation is often tested only through the UI, where foreign IDs are simply not shown, while asynchronous paths — queues, batch exports, a cache keyed without a tenant id — stay untested. Another trap is test environments with mTLS turned off "for convenience": then these tests prove nothing about production.
Уточнення. Класичні вразливості вебзастосунків розібрано в гайді Security; тут фокус на тому, як архітектура розподіляє довіру. Інтерв'юер може спитати про принцип zero trust: кожен запит автентифікується й авторизується незалежно від того, звідки він прийшов.
Follow-up. Classic web application vulnerabilities are covered in the Security guide; here the focus is on how the architecture distributes trust. The interviewer may ask about zero trust: every request is authenticated and authorised regardless of where it came from.
# 1. обхід gateway: внутрішній сервіс без токена
curl -s -o /dev/null -w "%{http_code}\n" \
http://orders.shop.svc:8080/orders/42 # чекаємо 401
# 2. токен з aud=billing-service, а не orders-service
curl -s -o /dev/null -w "%{http_code}\n" \
-H "Authorization: Bearer $BILLING_AUD_TOKEN" \
https://api.example.com/v1/orders/42 # чекаємо 401
# 3. тенант A просить замовлення тенанта B
curl -s -o /dev/null -w "%{http_code}\n" \
-H "Authorization: Bearer $TENANT_A_TOKEN" \
https://api.example.com/v1/orders/$TENANT_B_ORDER # 404 або 403
# 4. службовий ендпоінт ззовні
curl -s -o /dev/null -w "%{http_code}\n" \
https://api.example.com/actuator/env # чекаємо 404
# 1. gateway bypass: internal service without a token
curl -s -o /dev/null -w "%{http_code}\n" \
http://orders.shop.svc:8080/orders/42 # expect 401
# 2. token with aud=billing-service, not orders-service
curl -s -o /dev/null -w "%{http_code}\n" \
-H "Authorization: Bearer $BILLING_AUD_TOKEN" \
https://api.example.com/v1/orders/42 # expect 401
# 3. tenant A asks for tenant B's order
curl -s -o /dev/null -w "%{http_code}\n" \
-H "Authorization: Bearer $TENANT_A_TOKEN" \
https://api.example.com/v1/orders/$TENANT_B_ORDER # 404 or 403
# 4. internal endpoint from outside
curl -s -o /dev/null -w "%{http_code}\n" \
https://api.example.com/actuator/env # expect 404
aud у JWT: токен, виданий для іншого сервісу, має відхилятись, навіть якщо підпис валідний. Третій — ізоляцію тенантів за відомим ID, четвертий — що службові ендпоінти закриті на gateway. -w "%{http_code}\n" друкує лише код статусу.aud field in the JWT: a token issued for another service must be rejected even with a valid signature. The third checks tenant isolation by a known ID, the fourth that internal endpoints are closed at the gateway. -w "%{http_code}\n" prints only the status code.Що це. Балансувальник навантаження приймає вхідні з'єднання і розподіляє їх між кількома екземплярами сервісу. L4-балансувальник працює на рівні TCP і бачить лише адреси й порти, L7 розуміє HTTP: може маршрутизувати за шляхом, заголовком чи cookie і сам завершує TLS. Алгоритми бувають прості (round robin, weighted round robin) і адаптивні (least connections, найменший час відповіді). Балансувальник також регулярно опитує health-check кожного екземпляра і перестає слати трафік тим, хто не відповідає.
What it is. A load balancer accepts incoming connections and spreads them across several instances of a service. An L4 balancer works at the TCP level and sees only addresses and ports, while an L7 balancer understands HTTP: it can route by path, header or cookie and terminates TLS itself. Algorithms range from simple (round robin, weighted round robin) to adaptive (least connections, lowest response time). The balancer also polls each instance's health check and stops sending traffic to instances that do not answer.
Навіщо. Він дає дві речі: пропускну здатність понад можливості одного сервера і відмовостійкість, бо падіння одного екземпляра не зупиняє сервіс. Для QA це означає, що кожен наступний запит може потрапити на інший вузол, і будь-який стан, що живе в пам'яті процесу, стає джерелом плаваючих багів.
Why. It gives two things: throughput beyond what one server can handle, and fault tolerance, because losing one instance does not stop the service. For QA it means every next request may land on a different node, and any state kept in process memory becomes a source of intermittent bugs.
Як тестувати. Спершу перевіряю, що трафік справді розходиться: відправляю сотню запитів і рахую, який екземпляр відповів (за службовим заголовком чи полем у health-відповіді). Далі — сесії: логінюсь, роблю кілька дій і переконуюсь, що кошик чи форма не губляться між вузлами. Потім відмова: зупиняю один екземпляр під навантаженням і дивлюсь, скільки запитів отримали помилку і як швидко балансувальник його виключив. Окремо — розгортання: під час rolling update активні запити мають дозавершитись завдяки connection draining, а не обірватись з 502.
How to test. First I check that traffic really spreads: I send a hundred requests and count which instance answered, using a debug header or a field in the health response. Next come sessions: I log in, perform several actions and make sure the cart or a form is not lost between nodes. Then failure: I stop one instance under load and watch how many requests got an error and how quickly the balancer took it out of rotation. Deployment gets its own check: during a rolling update in-flight requests must finish thanks to connection draining instead of being cut off with a 502.
Підводні камені. Sticky sessions (прив'язка клієнта до вузла через cookie) маскують проблему зі станом у пам'яті: у тестовому середовищі все працює, а після перезапуску вузла користувачі втрачають сесію. Health-check, який лише відповідає 200 і не перевіряє залежності, тримає в ротації екземпляр, що вже не може працювати з базою. А тестове середовище з одним екземпляром взагалі не здатне виявити жоден із цих багів.
Pitfalls. Sticky sessions (pinning a client to a node with a cookie) hide the in-memory state problem: everything works in the test environment, but after a node restart users lose their session. A health check that just returns 200 without checking dependencies keeps an instance in rotation even though it can no longer talk to the database. And a test environment with a single instance cannot reveal any of these bugs at all.
Уточнення. Можуть спитати, чи сам балансувальник не є єдиною точкою відмови. Так, тому його теж дублюють: пара в режимі active-passive з плаваючою IP-адресою або керований хмарний балансувальник, який провайдер розносить по зонах доступності.
Follow-up. You may be asked whether the balancer itself is a single point of failure. It is, so it is duplicated too: an active-passive pair with a floating IP, or a managed cloud balancer that the provider spreads across availability zones.
for i in $(seq 1 100); do
curl -s -o /dev/null -D - https://api.example.com/health \
| grep -i '^x-served-by'
done | sort | uniq -c
# очікуємо приблизно рівні частки:
# 34 x-served-by: orders-7c9f-a
# 33 x-served-by: orders-7c9f-b
# 33 x-served-by: orders-7c9f-c
for i in $(seq 1 100); do
curl -s -o /dev/null -D - https://api.example.com/health \
| grep -i '^x-served-by'
done | sort | uniq -c
# expect roughly equal shares:
# 34 x-served-by: orders-7c9f-a
# 33 x-served-by: orders-7c9f-b
# 33 x-served-by: orders-7c9f-c
-D - виводить заголовки відповіді, grep залишає лише рядок з іменем екземпляра, а sort | uniq -c рахує, скільки разів відповів кожен. Заголовок X-Served-By тут умовний: у вашій системі це може бути інша назва або поле в JSON, яке розробники додають саме для діагностики.-D - prints the response headers, grep keeps only the line with the instance name, and sort | uniq -c counts how many times each instance answered. The X-Served-By header is illustrative: in your system it may have another name or be a JSON field that developers add precisely for diagnostics.Що це. Вертикальне масштабування — це більший сервер: більше CPU, пам'яті, швидший диск. Горизонтальне — більше однакових екземплярів за балансувальником. Перше просте, бо код не змінюється, але має стелю (найбільша доступна машина) і лишає одну точку відмови. Друге теоретично майже не обмежене, проте вимагає, щоб сервіс був stateless: сесії, файли й черги мають жити в спільних сховищах, а не на диску чи в пам'яті конкретного вузла.
What it is. Vertical scaling means a bigger server: more CPU, more memory, a faster disk. Horizontal scaling means more identical instances behind a load balancer. The first is simple because the code does not change, but it has a ceiling (the largest machine available) and leaves a single point of failure. The second is almost unlimited in theory, but it requires the service to be stateless: sessions, files and queues must live in shared stores, not on the disk or in the memory of a particular node.
Навіщо QA розрізняти. Від способу масштабування залежать типи багів. Вертикальний шлях ламається на межах ресурсів і під час перезапуску машини. Горизонтальний породжує баги узгодженості: два екземпляри одночасно запускають одну cron-задачу, локальний кеш на кожному вузлі показує різні дані, завантажений файл існує лише на одному сервері.
Why QA cares. The scaling approach determines the kinds of bugs. The vertical path breaks at resource limits and when the machine restarts. The horizontal path produces consistency bugs: two instances run the same cron job at once, a local cache on each node shows different data, an uploaded file exists on only one server.
Як тестувати. Для вертикального: навантажувальний тест до насичення, щоб знайти, який ресурс закінчується першим, і перевірка, що після збільшення машини пропускна здатність справді зросла, а не вперлась у блокування в базі. Для горизонтального: запуск щонайменше двох реплік у тестовому середовищі, перевірка фонових задач на дублювання, тест автомасштабування. В останньому я подаю ступінчасте навантаження і міряю, скільки часу минає від росту CPU до появи готових подів, які помилки бачать користувачі в цьому вікні і чи не рве scale-in активні запити.
How to test. For vertical: a load test up to saturation to find which resource runs out first, and a check that after a bigger machine throughput really grew instead of hitting a database lock. For horizontal: run at least two replicas in the test environment, check background jobs for duplication, and test autoscaling. In the latter I apply a stepped load and measure how long it takes from CPU growth to new pods being ready, which errors users see in that window, and whether scale-in cuts active requests.
Підводні камені. Автомасштабування реагує із затримкою: метрики збираються раз на 15 секунд, новому поду треба стартувати й прогріти кеш. Короткий різкий сплеск воно не врятує, тому тест лише на плавному рості дає хибну впевненість. Друга пастка — масштабувати сервіс, коли вузьке місце в базі: десять реплік замість двох лише збільшать кількість з'єднань і черги до неї.
Pitfalls. Autoscaling reacts with a delay: metrics are collected every 15 seconds, and a new pod must start and warm its cache. It will not save you from a short sharp spike, so a test with only a smooth ramp gives false confidence. The second trap is scaling the service when the bottleneck is the database: ten replicas instead of two only increase the number of connections and the queue in front of it.
Уточнення. Питання «а база даних?» — її масштабують інакше: вертикально, репліками для читання і шардингом для запису. Це окремі теми з власними ризиками, про них розділ «Дані».
Follow-up. The question "what about the database?" — it is scaled differently: vertically, with read replicas, and with sharding for writes. Those are separate topics with their own risks, covered in the Data section.
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: orders
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: orders
minReplicas: 2 # ніколи не один екземпляр
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
behavior:
scaleDown:
stabilizationWindowSeconds: 300
# тест: 50 -> 300 -> 50 RPS сходинками по 5 хв
# міряємо: час до нових Ready-подів, помилки у вікні,
# 502 під час scale-in, дублі cron-задач на репліках
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: orders
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: orders
minReplicas: 2 # never a single instance
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
behavior:
scaleDown:
stabilizationWindowSeconds: 300
# test: 50 -> 300 -> 50 RPS in 5-minute steps
# measure: time to new Ready pods, errors in that window,
# 502s during scale-in, duplicate cron jobs on replicas
minReplicas і maxReplicas. stabilizationWindowSeconds: 300 не дає різко прибирати поди після короткого спаду. Коментарі внизу — план тесту: ступінчасте навантаження і чотири спостереження, які показують, чи масштабування справді захищає користувача.minReplicas and maxReplicas. stabilizationWindowSeconds: 300 prevents removing pods abruptly after a short dip. The comments at the bottom are the test plan: a stepped load and four observations that show whether scaling really protects the user.Що це. Кеш — це швидка копія даних, яку тримають ближче до споживача, щоб не йти щоразу до повільного джерела. Шарів зазвичай кілька: кеш браузера (керується заголовками HTTP), CDN на краю мережі, кеш застосунку в Redis чи Memcached і внутрішній буфер самої бази. Найпоширеніший патерн у сервісах — cache-aside: сервіс спершу шукає ключ у Redis, при промаху читає базу, кладе результат у кеш з TTL і віддає клієнту.
What it is. A cache is a fast copy of data kept closer to the consumer so the slow source is not hit every time. There are usually several layers: the browser cache (driven by HTTP headers), a CDN at the network edge, an application cache in Redis or Memcached, and the database's own buffer. The most common pattern in services is cache-aside: the service first looks for the key in Redis, on a miss reads the database, puts the result into the cache with a TTL and returns it to the client.
Навіщо. Кеш знімає навантаження з бази і скорочує час відповіді з десятків мілісекунд до одиниць. Але кожна копія — це компроміс: ми міняємо свіжість даних на швидкість. Завдання тестувальника — переконатись, що цей компроміс саме такий, як домовились: ціну товару можна показувати з запізненням у хвилину, а залишок на рахунку — ні.
Why. A cache takes load off the database and cuts response time from tens of milliseconds to single digits. But every copy is a trade-off: we exchange freshness for speed. The tester's job is to make sure the trade-off is exactly what was agreed: a product price may be shown a minute late, an account balance may not.
Як тестувати. Базові перевірки: після першого запиту ключ з'являється в Redis, другий запит не йде в базу (видно в логах запитів або метриці hit ratio), TTL виставлено і він відповідає вимозі. Далі негативні сценарії: Redis недоступний — сервіс має працювати напряму з базою, повільніше, але без 500; кеш порожній після рестарту — перші хвилини навантаження на базу ростуть, і це треба виміряти. Корисно перевірити і ключі: чи входить у них усе, від чого залежить відповідь (мова, валюта, роль користувача), інакше один клієнт отримає дані іншого.
How to test. Basic checks: after the first request the key appears in Redis, the second request does not reach the database (visible in query logs or the hit ratio metric), and a TTL is set and matches the requirement. Then negative scenarios: Redis is down and the service must work directly against the database, slower but without 500s; the cache is empty after a restart, so load on the database grows for the first minutes, and that has to be measured. It also pays to check the keys: do they include everything the response depends on (language, currency, user role), otherwise one client gets another's data.
Підводні камені. Ключ без TTL живе вічно і з часом забиває пам'ять, після чого Redis починає витісняти ключі за політикою eviction, і hit ratio падає непомітно. Одночасне закінчення TTL у популярного ключа дає cache stampede: сотні запитів разом ідуть у базу. Кешування помилок чи порожніх відповідей теж буває багом: тимчасовий збій потім хвилинами показує «товар не знайдено».
Pitfalls. A key without a TTL lives forever and eventually fills memory, after which Redis starts evicting keys according to its eviction policy and the hit ratio drops unnoticed. A popular key expiring for everyone at once causes a cache stampede: hundreds of requests hit the database together. Caching errors or empty responses can be a bug too: a brief outage then shows "product not found" for minutes.
Уточнення. Якщо спитають, як захиститись від stampede: блокування на перерахунок одного ключа, випадковий розкид TTL (jitter) або фонове оновлення до закінчення терміну. Тестувати це можна, скинувши гарячий ключ під навантаженням і подивившись на кількість запитів до бази.
Follow-up. If asked how to protect against a stampede: a lock so only one request recomputes a key, random TTL spread (jitter), or background refresh before expiry. You can test it by deleting a hot key under load and watching the number of database queries.
redis-cli GET product:42
# (nil) -> промах, ключа ще немає
curl -s https://api.example.com/api/products/42 > /dev/null
redis-cli GET product:42
# "{\"id\":42,\"price\":199}" -> сервіс поклав значення
redis-cli TTL product:42
# (integer) 287 -> TTL є; -1 означало б ключ без терміну
redis-cli DEL product:42 # скидаємо, щоб знову тест промаху
redis-cli MONITOR # потік усіх команд у реальному часі
redis-cli GET product:42
# (nil) -> miss, the key does not exist yet
curl -s https://api.example.com/api/products/42 > /dev/null
redis-cli GET product:42
# "{\"id\":42,\"price\":199}" -> the service stored the value
redis-cli TTL product:42
# (integer) 287 -> TTL is set; -1 would mean a key that never expires
redis-cli DEL product:42 # reset to test the miss again
redis-cli MONITOR # live stream of every command
GET повертає (nil), якщо ключа немає. Після запиту до API ключ має з'явитися. TTL повертає залишок життя в секундах, -1 — ключ без терміну, -2 — ключа немає. MONITOR показує кожну команду до Redis, тож видно, чи сервіс читає кеш перед базою. На продакшені його не вмикають: він помітно гальмує сервер.GET returns (nil) when the key is absent. After the API call the key should appear. TTL returns the remaining lifetime in seconds, -1 means no expiry, -2 means no key. MONITOR shows every command sent to Redis, so you can see whether the service reads the cache before the database. It is not used in production because it noticeably slows the server down.Що це. Інвалідація — це момент, коли копія в кеші перестає вважатися правдивою: її видаляють, перезаписують або вона сама спливає за TTL. Стратегії різні. У cache-aside сервіс після запису в базу видаляє ключ. У write-through запис іде в кеш і базу разом. Буває й інвалідація подією: сервіс публікує «товар змінився», а слухачі чистять свої копії. Баг з'являється, коли хоча б один шлях зміни даних забуває про кеш або коли дві операції перетинаються в часі.
What it is. Invalidation is the moment a cached copy stops being considered true: it is deleted, overwritten or expires by TTL. Strategies differ. In cache-aside the service deletes the key after writing to the database. In write-through the write goes to cache and database together. There is also event-driven invalidation: a service publishes "product changed" and listeners purge their copies. A bug appears when at least one path that changes data forgets about the cache, or when two operations overlap in time.
Навіщо. Застарілі дані рідко видно у функціональному тесті, бо він змінює і читає за одну мить на порожньому середовищі. Користувач же бачить стару ціну в кошику, старий статус замовлення чи вже видалений коментар. Найчастіші джерела: зміна через адмінку, batch-імпорт або пряму міграцію SQL, що оминає код з інвалідацією; кілька шарів кешу, з яких почистили лише один; гонка між читанням і записом.
Why. Stale data is rarely visible in a functional test, because it writes and reads in the same instant on an empty environment. The user, however, sees an old price in the cart, an old order status or a comment that was already deleted. The most frequent sources: a change through the admin panel, a batch import or a direct SQL migration that bypasses the invalidation code; several cache layers where only one was purged; a race between a read and a write.
Як тестувати. Базовий тест має три кроки: прогріти кеш читанням, змінити дані через кожен шлях запису (API, адмінка, імпорт) і прочитати знову, очікуючи нове значення. Шляхів запису стільки ж, скільки тестів. Для багатошарового кешу перевіряю відповідь і на рівні API, і через CDN. Гонку відтворюю з інструментом, що вносить затримку (наприклад, Toxiproxy на з'єднанні з базою), або паралельним навантаженням: сотні читань і кілька записів, після чого порівнюю кеш з базою для всіх змінених ключів.
How to test. The basic test has three steps: warm the cache with a read, change the data through each write path (API, admin panel, import), and read again expecting the new value. There are as many tests as write paths. For a multi-layer cache I check the response both at the API level and through the CDN. I reproduce the race with a tool that injects latency (for example, Toxiproxy on the database connection) or with parallel load: hundreds of reads and a few writes, after which I compare the cache with the database for every changed key.
Підводні камені. Класична гонка cache-aside: читач отримує промах і читає зі старої бази, в цей час письменник оновлює базу й видаляє ключ, а потім читач кладе в кеш уже застаріле значення, і воно живе до кінця TTL. Лікування — коротший TTL як страховка, повторне видалення із затримкою або версії в значенні. Ще одна пастка — тест, що проходить лише тому, що TTL у тестовому середовищі виставлено в секунду.
Pitfalls. The classic cache-aside race: a reader gets a miss and reads the old value from the database, meanwhile a writer updates the database and deletes the key, and then the reader puts the already stale value into the cache, where it lives until the TTL ends. Remedies are a shorter TTL as a safety net, a delayed second delete, or versions stored with the value. Another trap is a test that passes only because the TTL in the test environment is set to one second.
Уточнення. На питання «скільки застарілості допустимо» відповідь дає не QA, а продукт: я прошу зафіксувати її як вимогу (наприклад, ціна оновлюється на сторінці не пізніше ніж за 60 секунд) і перевіряю саме це число.
Follow-up. The answer to "how much staleness is acceptable" comes from the product, not from QA: I ask for it to be written down as a requirement (for example, the price on the page updates within 60 seconds) and I test exactly that number.
час Читач (A) Письменник (B)
t1 GET product:42 -> промах
t2 SELECT price -> 199
t3 UPDATE price = 249
t4 DEL product:42
t5 SET product:42 = 199, TTL 1h
... до кінця TTL усі бачать 199, хоча в базі 249
time Reader (A) Writer (B)
t1 GET product:42 -> miss
t2 SELECT price -> 199
t3 UPDATE price = 249
t4 DEL product:42
t5 SET product:42 = 199, TTL 1h
... until the TTL ends everyone sees 199, the database has 249
import { test, expect } from '@playwright/test';
test('нова ціна видна одразу після зміни в адмінці', async ({ request }) => {
const url = '/api/products/42';
await request.get(url); // прогріваємо кеш
const upd = await request.patch('/admin/api/products/42', {
data: { price: 249 },
});
expect(upd.ok()).toBeTruthy();
const res = await request.get(url); // читання після запису
expect((await res.json()).price).toBe(249);
});
import { test, expect } from '@playwright/test';
test('new price is visible right after an admin change', async ({ request }) => {
const url = '/api/products/42';
await request.get(url); // warm the cache
const upd = await request.patch('/admin/api/products/42', {
data: { price: 249 },
});
expect(upd.ok()).toBeTruthy();
const res = await request.get(url); // read after write
expect((await res.json()).price).toBe(249);
});
request з Playwright шле HTTP-запити без браузера, а baseURL береться з конфігу. Для кожного шляху запису (імпорт, міграція) потрібен свій такий тест.request fixture sends HTTP requests without a browser, and baseURL comes from the config. Each write path (import, migration) needs its own test like this.Що це. CDN (Content Delivery Network) — мережа серверів у багатьох містах, які зберігають копії відповідей origin-сервера і віддають їх користувачу з найближчої точки. Класично там живе статика: JS, CSS, зображення, відео. Сучасні CDN кешують і HTML чи відповіді API, виконують код на краю, завершують TLS і фільтрують атаки. Що і скільки кешувати, CDN вирішує за заголовками Cache-Control від origin і за власними правилами, а ключ кешу зазвичай складається з хоста, шляху і рядка запиту.
What it is. A CDN (Content Delivery Network) is a network of servers in many cities that keep copies of the origin server's responses and serve them to the user from the nearest point. Classically it holds static assets: JS, CSS, images, video. Modern CDNs also cache HTML or API responses, run code at the edge, terminate TLS and filter attacks. What to cache and for how long is decided by the origin's Cache-Control headers plus the CDN's own rules, and the cache key usually consists of the host, the path and the query string.
Навіщо. Користувач у Києві не чекає на відповідь із сервера у Франкфурті, а origin отримує лише частку трафіку. Ціна — ще один шар копій, що може віддати не ту версію: старий бандл після релізу, чужу персональну сторінку або зображення без потрібного формату для конкретного браузера.
Why. A user in Kyiv does not wait for an answer from a server in Frankfurt, and the origin gets only a fraction of the traffic. The price is one more layer of copies that can serve the wrong version: an old bundle after a release, someone else's personal page, or an image without the format a particular browser needs.
Як тестувати. Дивлюсь на заголовки: для статики з хешем в імені — довгий max-age і immutable; для HTML — короткий термін або ревалідація; для сторінок акаунта — private чи no-store. Заголовок Age показує, скільки секунд відповідь лежить у кеші, а службовий X-Cache (назва залежить від провайдера) каже HIT чи MISS. Після релізу перевіряю, що нова версія HTML посилається на нові імена бандлів. Окремо — Vary: якщо відповідь залежить від мови чи стиснення, це має бути в ключі кешу. І обов'язково тест з двома користувачами: другий не повинен побачити відповідь, закешовану для першого.
How to test. I look at the headers: for static files with a hash in the name, a long max-age plus immutable; for HTML, a short lifetime or revalidation; for account pages, private or no-store. The Age header shows how many seconds the response has been cached, and a debug header such as X-Cache (the name depends on the provider) says HIT or MISS. After a release I check that the new HTML references the new bundle names. Vary gets its own check: if the response depends on language or compression, that must be part of the cache key. And always a two-user test: the second user must not see a response cached for the first.
Підводні камені. Найнебезпечніший баг — закешована персональна відповідь: origin забув поставити private, і CDN віддає сторінку профілю одного користувача всім. Другий — HTML довго живе в кеші, а старі бандли вже видалили з origin, тож сторінка ламається до очищення. Третій — ключ кешу ігнорує параметри запиту, і різні фільтри каталогу показують однаковий результат.
Pitfalls. The most dangerous bug is a cached personal response: the origin forgot to set private, and the CDN serves one user's profile page to everyone. The second is HTML living long in the cache while the old bundles were already deleted from origin, so the page breaks until a purge. The third is a cache key that ignores query parameters, so different catalogue filters show the same result.
Уточнення. Можуть спитати, як перевірити географію. Для цього є тести з різних регіонів через synthetic monitoring або VPN-точки, а також перевірка, що геозалежний контент (ціни, мова) або не кешується спільно, або має регіон у ключі.
Follow-up. You may be asked how to test geography. There are checks from different regions via synthetic monitoring or VPN exit points, plus a check that geo-dependent content (prices, language) is either not shared in the cache or has the region in its key.
# статика з хешем в імені: довгий кеш, HIT на краю
$ curl -sI https://cdn.example.com/assets/app.3f9c1.js
HTTP/2 200
cache-control: public, max-age=31536000, immutable
age: 5321
x-cache: HIT
vary: Accept-Encoding
# сторінка акаунта: не має кешуватись спільно
$ curl -sI https://www.example.com/account -H "Cookie: sid=u1"
HTTP/2 200
cache-control: private, no-store
x-cache: MISS
# static file with a hash in its name: long cache, HIT at the edge
$ curl -sI https://cdn.example.com/assets/app.3f9c1.js
HTTP/2 200
cache-control: public, max-age=31536000, immutable
age: 5321
x-cache: HIT
vary: Accept-Encoding
# account page: must not be cached in a shared cache
$ curl -sI https://www.example.com/account -H "Cookie: sid=u1"
HTTP/2 200
cache-control: private, no-store
x-cache: MISS
curl -sI робить HEAD-запит і друкує лише заголовки. max-age=31536000 — рік, immutable каже браузеру не ревалідувати файл, тому така політика безпечна лише для імен з хешем вмісту. age — секунди в кеші, x-cache — підказка CDN про влучання. private, no-store забороняє спільним кешам, зокрема CDN, зберігати відповідь.curl -sI sends a HEAD request and prints only the headers. max-age=31536000 is one year, and immutable tells the browser not to revalidate the file, so this policy is safe only for names with a content hash. age is seconds spent in the cache, x-cache is the CDN's hint about a hit. private, no-store forbids shared caches, including the CDN, from storing the response.Що це. Rate limiting обмежує кількість запитів від одного клієнта за проміжок часу. Клієнт визначається ключем: API-ключ, користувач, IP або їх комбінація. Алгоритми різні. Фіксоване вікно рахує запити в межах хвилини і скидає лічильник на її початку. Ковзне вікно рахує за останні 60 секунд від поточного моменту. Token bucket поповнює «відро» жетонів зі сталою швидкістю і дозволяє короткий сплеск до розміру відра. Коли ліміт вичерпано, сервер відповідає 429 Too Many Requests і бажано додає Retry-After.
What it is. Rate limiting caps the number of requests from one client over a period of time. The client is identified by a key: an API key, a user, an IP or a combination. Algorithms differ. A fixed window counts requests within a minute and resets the counter at its start. A sliding window counts over the last 60 seconds from the current moment. A token bucket refills with tokens at a constant rate and allows a short burst up to the bucket size. When the limit is exhausted, the server replies 429 Too Many Requests and ideally adds Retry-After.
Навіщо. Ліміти захищають сервіс від перевантаження, зловживань і перебору паролів, а також розподіляють ресурси між тарифами. Для QA тут дві сторони: ліміт має спрацьовувати для порушника і не зачіпати чесного користувача, а наш власний клієнт має правильно реагувати на чужі 429.
Why. Limits protect a service from overload, abuse and password guessing, and they split resources between pricing plans. For QA there are two sides: the limit must kick in for the offender without touching an honest user, and our own client must react correctly to someone else's 429.
Як тестувати. Даю серію запитів, трохи більшу за ліміт, і рахую коди: має бути рівно стільки 200, скільки дозволено, решта 429. Перевіряю вміст відповіді: Retry-After, заголовки залишку (RateLimit-Remaining або X-RateLimit-Remaining, залежно від API), зрозуміле тіло помилки. Далі чекаю вказаний час і переконуюсь, що доступ відновився. Ізоляцію перевіряю паралельно другим ключем: його запити проходять, поки перший заблоковано. Для клієнтського боку ставлю мок, що повертає 429 з Retry-After: 5, і дивлюсь, що наш сервіс чекає, а не ретраїть одразу.
How to test. I send a burst slightly above the limit and count status codes: there must be exactly as many 200s as allowed and the rest 429. I check the response content: Retry-After, remaining-quota headers (RateLimit-Remaining or X-RateLimit-Remaining, depending on the API), a clear error body. Then I wait the stated time and make sure access comes back. I check isolation in parallel with a second key: its requests go through while the first one is blocked. On the client side I set up a mock that returns 429 with Retry-After: 5 and watch that our service waits instead of retrying immediately.
Підводні камені. Якщо лічильник живе в пам'яті кожного екземпляра, то при трьох репліках ліміт 100 насправді стає 300: балансувальник розкидає запити, і кожен вузол рахує свою частку. Тест проти одного екземпляра цього не покаже. Друга пастка — ліміт за IP за корпоративним NAT, коли весь офіс клієнта отримує 429 через одного активного співробітника. Третя — фіксоване вікно на межі хвилин пропускає вдвічі більше запитів, ніж обіцяно.
Pitfalls. If the counter lives in the memory of each instance, then with three replicas a limit of 100 actually becomes 300: the balancer spreads requests and each node counts its own share. A test against a single instance will not show it. The second trap is a per-IP limit behind a corporate NAT, where a client's entire office gets 429 because of one busy employee. The third is a fixed window that lets through twice the promised number of requests at the boundary between minutes.
Уточнення. Якщо спитають, 429 чи 503: 429 — клієнт перевищив свою квоту, винен він; 503 — сервер перевантажений або недоступний для всіх. Обидва можуть мати Retry-After, але реакція моніторингу на них різна.
Follow-up. If asked 429 or 503: 429 means the client exceeded its own quota and is at fault; 503 means the server is overloaded or unavailable for everyone. Both may carry Retry-After, but monitoring reacts to them differently.
# ліміт за договором: 100 запитів за хвилину на ключ
for i in $(seq 1 120); do
curl -s -o /dev/null -w '%{http_code}\n' \
-H "X-Api-Key: $KEY_A" "https://api.example.com/v1/search?q=a"
done | sort | uniq -c
# 100 200
# 20 429 <- якщо 429 немає, ліміт рахується на кожній репліці окремо
# вміст відмови
curl -s -D - -o /dev/null -H "X-Api-Key: $KEY_A" \
"https://api.example.com/v1/search?q=a" | grep -iE 'retry-after|ratelimit'
# інший ключ не заблоковано
curl -s -o /dev/null -w '%{http_code}\n' \
-H "X-Api-Key: $KEY_B" "https://api.example.com/v1/search?q=a" # 200
# agreed limit: 100 requests per minute per key
for i in $(seq 1 120); do
curl -s -o /dev/null -w '%{http_code}\n' \
-H "X-Api-Key: $KEY_A" "https://api.example.com/v1/search?q=a"
done | sort | uniq -c
# 100 200
# 20 429 <- no 429 at all: the limit is counted per replica
# content of the rejection
curl -s -D - -o /dev/null -H "X-Api-Key: $KEY_A" \
"https://api.example.com/v1/search?q=a" | grep -iE 'retry-after|ratelimit'
# another key is not blocked
curl -s -o /dev/null -w '%{http_code}\n' \
-H "X-Api-Key: $KEY_B" "https://api.example.com/v1/search?q=a" # 200
-w '%{http_code}\n' друкує лише код статусу, а sort | uniq -c групує їх. Другий запит показує заголовки відмови через grep -iE без урахування регістру. Третій доводить, що ліміт прив'язаний до ключа, а не глобальний. Тест треба запускати через балансувальник, а не на один под, інакше лічильник на репліках не перевіриться.-w '%{http_code}\n' prints only the status code, and sort | uniq -c groups them. The second request shows the rejection headers through a case-insensitive grep -iE. The third proves the limit is tied to the key rather than global. The test must run through the load balancer, not against one pod, otherwise per-replica counting goes unchecked.Що це. Навантажувальний тест системи відповідає на питання «чи витримаємо ми реальний трафік з потрібною якістю і де зламаємось першими». Він складається з профілю навантаження, середовища, критеріїв успіху й аналізу. Профіль беруть з продакшену: які ендпоінти викликають, у якій пропорції, з якою інтенсивністю в пік, які дані (гарячі й холодні товари, розмір кошика). Критерії записують як пороги: p95 і p99 затримки та частка помилок для кожного сценарію.
What it is. A system load test answers "can we handle real traffic at the required quality, and where do we break first". It consists of a load profile, an environment, success criteria and analysis. The profile comes from production: which endpoints are called, in what proportion, at what peak intensity, with what data (hot and cold products, cart size). Criteria are written as thresholds: p95 and p99 latency and the error rate for each scenario.
Навіщо. Функціональні тести не бачать проблем, які з'являються лише під конкуренцією: вичерпаний пул з'єднань, блокування рядків, черга в балансувальнику, повільний зовнішній API, збирання сміття. Такі дефекти проявляються зростанням p99 задовго до відмови, і тест має їх знайти до Чорної п'ятниці.
Why. Functional tests do not see problems that appear only under concurrency: an exhausted connection pool, row locks, a queue in the balancer, a slow external API, garbage collection. Such defects show up as a rising p99 long before an outage, and the test must find them before Black Friday.
Як тестувати. Спершу обираю модель. Відкрита модель (arrival rate) задає кількість запитів за секунду незалежно від того, як швидко відповідає система, і чесно показує, що буде з реальними користувачами, які не чекають на попередніх. Закрита модель (фіксовані віртуальні користувачі) при уповільненні системи сама зменшує тиск і маскує проблему. Далі ступінчасто нарощую навантаження до цілі, тримаю плато і йду вище до точки насичення. Вузьке місце шукаю за методами USE для ресурсів (utilization, saturation, errors) і RED для сервісів (rate, errors, duration): той компонент, чия затримка росте першою при насиченні ресурсу, і є обмеженням.
How to test. First I pick the model. The open model (arrival rate) sets requests per second regardless of how fast the system answers, and honestly shows what happens to real users who do not wait for each other. The closed model (a fixed number of virtual users) automatically lowers the pressure when the system slows down and masks the problem. Then I ramp the load in steps to the target, hold a plateau, and go higher to the saturation point. I look for the bottleneck with USE for resources (utilization, saturation, errors) and RED for services (rate, errors, duration): the component whose latency grows first as a resource saturates is the constraint.
Підводні камені. Тест на одних і тих самих даних весь час влучає в кеш і дає нереально гарні цифри. Генератор навантаження сам упирається в CPU і вимірює себе, а не систему. Середнє значення затримки ховає хвіст: 99 швидких і один десятисекундний запит дають пристойне середнє. Середовище вдвічі менше за продакшен не дає лінійно масштабованих висновків, бо спільні ресурси, як база, не зменшуються пропорційно.
Pitfalls. A test on the same data all the time hits the cache and produces unrealistically good numbers. The load generator itself saturates its CPU and measures itself rather than the system. The average latency hides the tail: 99 fast requests and one ten-second request give a respectable mean. An environment half the size of production does not give linearly scalable conclusions, because shared resources like the database do not shrink proportionally.
Уточнення. Типове продовження — «чим soak-тест відрізняється від stress-тесту». Soak тримає звичайне навантаження годинами і ловить витоки пам'яті та з'єднань, stress іде за межі, щоб побачити, як система відмовляє і чи відновлюється сама.
Follow-up. A typical follow-up is "how does a soak test differ from a stress test". Soak holds normal load for hours and catches memory and connection leaks, stress goes beyond the limits to see how the system fails and whether it recovers on its own.
import http from 'k6/http';
import { check } from 'k6';
export const options = {
scenarios: {
browse: { // 90% трафіку: перегляд каталогу
executor: 'constant-arrival-rate',
rate: 180, timeUnit: '1s', duration: '15m',
preAllocatedVUs: 100, maxVUs: 400, exec: 'browse',
},
checkout: { // 10%: оформлення, росте сходинками
executor: 'ramping-arrival-rate',
startRate: 5, timeUnit: '1s',
preAllocatedVUs: 50, maxVUs: 200, exec: 'checkout',
stages: [
{ target: 20, duration: '5m' },
{ target: 20, duration: '10m' },
],
},
},
thresholds: {
http_req_failed: ['rate<0.01'],
'http_req_duration{scenario:browse}': ['p(95)<300'],
'http_req_duration{scenario:checkout}': ['p(95)<800', 'p(99)<1500'],
},
};
const BASE = __ENV.BASE_URL;
export function browse() {
const id = Math.floor(Math.random() * 50000) + 1; // холодні й гарячі id
const res = http.get(BASE + '/api/products/' + id);
check(res, { 'каталог 200': (r) => r.status === 200 });
}
export function checkout() {
const body = JSON.stringify({ sku: 'SKU-' + (__VU % 500), qty: 1 });
const res = http.post(BASE + '/api/checkout', body, {
headers: { 'Content-Type': 'application/json' },
});
check(res, { 'замовлення 201': (r) => r.status === 201 });
}
import http from 'k6/http';
import { check } from 'k6';
export const options = {
scenarios: {
browse: { // 90% of traffic: catalogue browsing
executor: 'constant-arrival-rate',
rate: 180, timeUnit: '1s', duration: '15m',
preAllocatedVUs: 100, maxVUs: 400, exec: 'browse',
},
checkout: { // 10%: checkout, ramps in steps
executor: 'ramping-arrival-rate',
startRate: 5, timeUnit: '1s',
preAllocatedVUs: 50, maxVUs: 200, exec: 'checkout',
stages: [
{ target: 20, duration: '5m' },
{ target: 20, duration: '10m' },
],
},
},
thresholds: {
http_req_failed: ['rate<0.01'],
'http_req_duration{scenario:browse}': ['p(95)<300'],
'http_req_duration{scenario:checkout}': ['p(95)<800', 'p(99)<1500'],
},
};
const BASE = __ENV.BASE_URL;
export function browse() {
const id = Math.floor(Math.random() * 50000) + 1; // cold and hot ids
const res = http.get(BASE + '/api/products/' + id);
check(res, { 'catalogue 200': (r) => r.status === 200 });
}
export function checkout() {
const body = JSON.stringify({ sku: 'SKU-' + (__VU % 500), qty: 1 });
const res = http.post(BASE + '/api/checkout', body, {
headers: { 'Content-Type': 'application/json' },
});
check(res, { 'order 201': (r) => r.status === 201 });
}
constant-arrival-rate запускає 180 ітерацій за секунду незалежно від швидкості відповідей, ramping-arrival-rate змінює інтенсивність за stages. maxVUs — запас віртуальних користувачів: якщо його не вистачає, k6 попереджає про пропущені ітерації, і це вже сигнал уповільнення. k6 автоматично додає тег scenario, тому пороги задаються окремо для кожного сценарію. __ENV.BASE_URL передається через -e BASE_URL=....constant-arrival-rate executor starts 180 iterations per second regardless of response speed, and ramping-arrival-rate changes the intensity along its stages. maxVUs is the pool of virtual users: if it runs out, k6 warns about dropped iterations, which is already a slowdown signal. k6 adds the scenario tag automatically, so thresholds are set per scenario. __ENV.BASE_URL is passed with -e BASE_URL=....Контекст і ризики. Сервіс приймає довгий URL, повертає короткий код на кшталт sho.rt/aZ3kQ9x і перенаправляє за ним. Читань на порядки більше, ніж записів, тому є кеш перед сховищем, а кліки рахує асинхронний конвеєр аналітики. Головні ризики: два різні URL отримали однаковий код, перенаправлення веде не туди або його неможливо змінити, сервіс став інструментом фішингу, вірусне посилання поклало сховище, а статистика кліків бреше.
Context and risks. The service takes a long URL, returns a short code like sho.rt/aZ3kQ9x and redirects by it. Reads outnumber writes by orders of magnitude, so there is a cache in front of storage, and clicks are counted by an asynchronous analytics pipeline. Key risks: two different URLs got the same code, a redirect leads to the wrong place or cannot be changed, the service becomes a phishing tool, a viral link takes down storage, and click statistics lie.
Стратегія по рівнях. Юніт-тести на генерацію коду: алфавіт base62, довжина, відсутність неоднозначних символів, якщо так вирішили. Компонентні тести з реальною базою в Testcontainers: унікальний індекс на коді і повтор генерації при конфлікті, власні аліаси, термін дії. API-тести на валідацію вхідного URL. Контракт між сервісом редиректів і аналітикою. Навантажувальний тест з розподілом Zipf, бо кілька кодів отримують більшість трафіку. І synthetic-перевірка на продакшені: створити посилання, перейти, побачити клік у статистиці.
Strategy by layer. Unit tests for code generation: base62 alphabet, length, no ambiguous characters if that was decided. Component tests with a real database in Testcontainers: a unique index on the code and regeneration on conflict, custom aliases, expiry. API tests for input URL validation. A contract between the redirect service and analytics. A load test with a Zipf distribution, because a few codes get most of the traffic. And a synthetic check in production: create a link, follow it, see the click in the statistics.
Ключові тест-кейси. Колізії: примусово підсунути генератору зайнятий код і перевірити, що створено інший, а не перезаписано старе посилання. Редирект: 301 кешується браузером назавжди, тому зміна цілі чи підрахунок повторних кліків перестануть працювати; 302 чи 307 ходять на сервер щоразу. Команда має свідомо обрати код, а тест — закріпити його. Валідація: схеми javascript: і data: відхиляються, URL на сам сервіс не створює петлю, адреси з чорного списку блокуються. Термін дії: після нього 404 або 410 і очищення кешу. Аналітика: лічильник збігається з кількістю кліків у межах домовленої затримки.
Key test cases. Collisions: force the generator to produce an occupied code and check a different one is created instead of overwriting the old link. Redirects: a 301 is cached by the browser permanently, so changing the target or counting repeat clicks stops working; a 302 or 307 hits the server every time. The team must choose the code deliberately and a test must pin it. Validation: javascript: and data: schemes are rejected, a URL pointing to the service itself does not create a loop, blocklisted addresses are refused. Expiry: afterwards a 404 or 410 and the cache is purged. Analytics: the counter matches the number of clicks within the agreed delay.
Підводні камені. Послідовні числові ідентифікатори роблять коди передбачуваними: хтось переглядає чужі приватні посилання простим перебором. Кеш на редирект переживає видалення посилання, заблокованого за фішинг. Навантажувальний тест із рівномірним розподілом кодів показує чудовий hit ratio, а вірусне посилання в реальності б'є в один ключ і один шард.
Pitfalls. Sequential numeric ids make codes predictable: someone browses other people's private links by simple enumeration. A cached redirect outlives deletion of a link blocked for phishing. A load test with a uniform code distribution shows a great hit ratio, while in reality a viral link hits one key and one shard.
Уточнення. Якщо спитають про пріоритети при обмеженому часі: спершу коректність редиректу і відсутність колізій, потім зловживання, потім продуктивність гарячих ключів, а точність аналітики останньою, бо її можна перерахувати з логів.
Follow-up. If asked about priorities with limited time: first redirect correctness and no collisions, then abuse, then hot-key performance, with analytics accuracy last, because it can be recomputed from logs.
POST /links ---> Link API ---> генератор коду (base62)
| колізія? повтор, не перезапис
v
PostgreSQL (UNIQUE code)
^
GET /aZ3kQ9x ---> Redirect svc ---> Redis (гарячі коди)
| 301 чи 302? видалене посилання в кеші?
v
Kafka: click-events ---> Analytics (eventual)
POST /links ---> Link API ---> code generator (base62)
| collision? retry, never overwrite
v
PostgreSQL (UNIQUE code)
^
GET /aZ3kQ9x ---> Redirect svc ---> Redis (hot codes)
| 301 or 302? deleted link still cached?
v
Kafka: click-events ---> Analytics (eventual)
Ризик Рівень Перевірка
----------------------- ----------- ---------------------------
колізія кодів компонент зайнятий код -> новий код
неправильний редирект API Location і статус 301/302
фішинг / javascript: API 400 і запис у журнал abuse
гарячий ключ навантаження Zipf-розподіл, p99 і hit ratio
термін дії компонент 410 після expiry, кеш очищено
точність кліків контракт+E2E лічильник = кліки за 60 с
Risk Layer Check
----------------------- ----------- ---------------------------
code collision component taken code -> new code
wrong redirect API Location and 301/302 status
phishing / javascript: API 400 and an abuse log entry
hot key load Zipf distribution, p99, hit ratio
expiry component 410 after expiry, cache purged
click accuracy contract+E2E counter = clicks within 60 s
Що це. Реляційні бази (PostgreSQL, MySQL) зберігають дані в таблицях зі строгою схемою, зв'язують їх через зовнішні ключі та JOIN і дають транзакції ACID «з коробки». NoSQL — це збірна назва для різних моделей: документні (MongoDB), ключ-значення (Redis, DynamoDB), широкі колонки (Cassandra), графові (Neo4j). Спільне в них одне: база менше контролює структуру даних і частіше жертвує суворою узгодженістю заради масштабування.
What it is. Relational databases (PostgreSQL, MySQL) store data in tables with a strict schema, link them through foreign keys and JOINs, and give ACID transactions out of the box. NoSQL is an umbrella term for different models: document (MongoDB), key-value (Redis, DynamoDB), wide-column (Cassandra), graph (Neo4j). What they share is that the database controls the shape of data less and more often trades strict consistency for scale.
Навіщо QA це знати. У SQL-базі частину багів ловить сама база: NOT NULL, UNIQUE, CHECK і зовнішній ключ не дадуть записати сміття. У документній базі ці правила живуть у коді застосунку, і якщо розробник забув валідацію, документ без обов'язкового поля спокійно збережеться. Отже, чим менше гарантій дає сховище, тим більше перевірок цілісності переходить у тести.
Why QA needs it. In an SQL database the database itself catches part of the bugs: NOT NULL, UNIQUE, CHECK and a foreign key refuse to store garbage. In a document database those rules live in application code, and if a developer forgot validation, a document without a required field is saved without complaint. So the fewer guarantees the storage gives, the more integrity checks move into the tests.
Як тестувати. Для NoSQL я перевіряю валідацію на вході API: відсутні поля, зайві поля, неправильні типи. Окремо дивлюсь на денормалізацію: якщо ім'я клієнта скопійоване в кожне замовлення, то після перейменування старі замовлення можуть показувати старе ім'я, і треба знати, чи це баг, чи задумано. Ще один клас тестів — старі документи: у колекції живуть записи, створені три версії коду тому, і нова версія має їх читати без падіння.
How to test. For NoSQL I check validation at the API boundary: missing fields, extra fields, wrong types. I also look at denormalisation: if the customer name is copied into every order, after a rename old orders may show the old name, and I need to know whether that is a bug or by design. Another class of tests is old documents: the collection holds records created three code versions ago, and the new version must read them without crashing.
Підводні камені. «Schemaless» не означає «без схеми»: схема просто неявна і розмазана по коду. Друга пастка — налаштування узгодженості: у MongoDB читання з secondary або write concern 1 дають інші гарантії, ніж читання з primary з majority, і тест, що проходить на одному вузлі локально, може флейкати в кластері. Третя — звіти: агрегації без JOIN часто пишуть вручну, і там легко загубити записи.
Pitfalls. "Schemaless" does not mean "no schema": the schema is just implicit and spread across the code. The second trap is consistency settings: in MongoDB, reading from a secondary or using write concern 1 gives different guarantees than reading from the primary with majority, and a test that passes on a single local node can be flaky on a cluster. The third is reports: aggregations without JOINs are often written by hand, and records get lost there easily.
Уточнення. Можуть спитати, що обрати для нового сервісу. Чесна відповідь QA: вибір робить команда, а я питаю, які інваріанти має тримати дані і хто їх гарантує — база чи код. Від відповіді залежить, скільки негативних тестів на цілісність треба написати.
Follow-up. You may be asked what to choose for a new service. An honest QA answer: the team makes the choice, and I ask which invariants the data must hold and who guarantees them, the database or the code. The answer decides how many negative integrity tests need to be written.
SQL: дві таблиці, база стежить за зв'язком
orders(id=42, customer_id=7, status='PAID')
order_items(order_id=42, sku='A1', qty=2)
-> customer_id=999 без клієнта: помилка FK
Документ: усе разом, зв'язок стежить код
{ "_id": 42, "customer": {"id": 7, "name": "Olena"},
"status": "PAID", "items": [{"sku": "A1", "qty": 2}] }
-> документ без "status" збережеться без помилки
SQL: two tables, the database guards the link
orders(id=42, customer_id=7, status='PAID')
order_items(order_id=42, sku='A1', qty=2)
-> customer_id=999 with no customer: FK error
Document: all in one, the code guards the link
{ "_id": 42, "customer": {"id": 7, "name": "Olena"},
"status": "PAID", "items": [{"sku": "A1", "qty": 2}] }
-> a document without "status" is saved, no error
test('замовлення без status відхиляється', async ({ request }) => {
const res = await request.post('/api/orders', {
data: { customerId: 7, items: [{ sku: 'A1', qty: 2 }] },
});
expect(res.status()).toBe(400);
// і в колекції не з'явився "напівдокумент"
const found = await db.collection('orders').countDocuments({ customerId: 7 });
expect(found).toBe(0);
});
test('an order without status is rejected', async ({ request }) => {
const res = await request.post('/api/orders', {
data: { customerId: 7, items: [{ sku: 'A1', qty: 2 }] },
});
expect(res.status()).toBe(400);
// and no half-built document appeared in the collection
const found = await db.collection('orders').countDocuments({ customerId: 7 });
expect(found).toBe(0);
});
countDocuments), щоб переконатись, що відмова не залишила часткового запису.countDocuments) to make sure the rejection left no partial record.Що це. ACID — чотири гарантії транзакції. Atomicity: усі зміни транзакції застосовуються разом або жодна. Consistency: після транзакції дані задовольняють усім обмеженням схеми (CHECK, UNIQUE, зовнішні ключі). Isolation: паралельні транзакції не бачать проміжних станів одна одної в тій мірі, яку обіцяє рівень ізоляції. Durability: після COMMIT дані переживуть падіння процесу чи сервера, бо записані в журнал на диск.
What it is. ACID is four guarantees of a transaction. Atomicity: all changes in a transaction apply together or none do. Consistency: after the transaction the data satisfies every schema constraint (CHECK, UNIQUE, foreign keys). Isolation: concurrent transactions do not see each other's intermediate states, to the degree the isolation level promises. Durability: after COMMIT the data survives a process or server crash, because it is written to the log on disk.
Навіщо QA це знати. Найдорожчі баги з грошима й залишками — це не неправильна формула, а половина операції: гроші списались, а на інший рахунок не прийшли; замовлення створилось, а позиції ні. Такі баги виникають, коли розробник виконав кілька запитів без спільної транзакції або спіймав виняток і все одно зробив commit.
Why QA needs it. The most expensive bugs with money and stock are not a wrong formula but half an operation: money left one account and never reached the other; the order was created but its items were not. Such bugs appear when a developer ran several statements without a shared transaction, or caught an exception and committed anyway.
Як тестувати. Атомарність перевіряю через збій посередині: змушую другий крок впасти (неіснуючий рахунок, порушення CHECK, вимкнена залежність у тестовому середовищі) і дивлюсь, що перший крок відкотився. Consistency — спробою записати дані, що порушують обмеження, і очікуванням помилки, а не тихого збереження. Durability на рівні застосунку — перезапуском сервісу або контейнера бази одразу після успішної відповіді: те, що API назвав збереженим, має бути на місці.
How to test. I check atomicity by failing in the middle: I force the second step to fail (a non-existent account, a CHECK violation, a dependency switched off in the test environment) and verify the first step was rolled back. Consistency: I try to write data that breaks a constraint and expect an error, not a silent save. Durability at the application level: restart the service or the database container right after a successful response; whatever the API called saved must still be there.
Підводні камені. Транзакція бази діє лише в межах однієї бази. Якщо сервіс пише в PostgreSQL і шле подію в Kafka, ACID не покриває цю пару, і можлива ситуація «запис є, події немає» — для цього існують outbox-патерн і сага. Ще одна пастка — Consistency в ACID і Consistency в CAP означають різне: перше про обмеження схеми, друге про те, чи всі вузли бачать останній запис.
Pitfalls. A database transaction only covers one database. If a service writes to PostgreSQL and publishes an event to Kafka, ACID does not cover the pair, and "row exists, event missing" is possible; that is what the outbox pattern and sagas are for. Another trap: Consistency in ACID and Consistency in CAP mean different things. The first is about schema constraints, the second is about whether all nodes see the latest write.
Уточнення. Часто питають, чи достатньо юніт-тесту з моком репозиторію. Ні: мок не має транзакцій, тому атомарність перевіряється інтеграційним тестом на справжній базі, наприклад у Testcontainers.
Follow-up. A common question is whether a unit test with a mocked repository is enough. It is not: a mock has no transactions, so atomicity is checked with an integration test against a real database, for example in Testcontainers.
-- залишки до тесту: рахунок 1 = 500, рахунку 999 не існує
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
INSERT INTO ledger (account_id, amount) VALUES (999, 100);
-- ERROR: insert or update on table "ledger" violates
-- foreign key constraint "ledger_account_id_fkey"
ROLLBACK;
-- перевірка: списання теж відкотилось
SELECT balance FROM accounts WHERE id = 1; -- очікуємо 500
SELECT COUNT(*) FROM ledger WHERE account_id = 999; -- 0
-- balances before the test: account 1 = 500, account 999 does not exist
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
INSERT INTO ledger (account_id, amount) VALUES (999, 100);
-- ERROR: insert or update on table "ledger" violates
-- foreign key constraint "ledger_account_id_fkey"
ROLLBACK;
-- check: the debit was rolled back too
SELECT balance FROM accounts WHERE id = 1; -- expect 500
SELECT COUNT(*) FROM ledger WHERE account_id = 999; -- 0
ROLLBACK, тому перше UPDATE теж зникає. Контрольні SELECT після відкату — власне тест: баланс незмінний, у ledger нічого немає. В API-тесті той самий збій викликають через запит на неіснуючий рахунок.ROLLBACK, so the first UPDATE disappears too. The control SELECT statements after the rollback are the actual test: the balance is unchanged and the ledger is empty. In an API test the same failure is triggered by a request to a non-existent account.Що це. Аномалії — це ефекти, які паралельні транзакції бачать одна від одної. Dirty read: читання незакоміченого. Non-repeatable read: той самий рядок двічі читається з різним значенням. Phantom: повторний запит з умовою повертає нові рядки. Lost update: дві транзакції прочитали значення, кожна записала своє, і одна зміна зникла. Write skew: дві транзакції читають спільну умову і змінюють різні рядки так, що разом порушують правило. Рівні READ COMMITTED, REPEATABLE READ і SERIALIZABLE відсікають дедалі більше аномалій.
What it is. Anomalies are the effects concurrent transactions see from each other. Dirty read: reading uncommitted data. Non-repeatable read: the same row read twice returns different values. Phantom: re-running a query with a condition returns new rows. Lost update: two transactions read a value, each writes its own, and one change vanishes. Write skew: two transactions read a shared condition and change different rows so that together they break a rule. READ COMMITTED, REPEATABLE READ and SERIALIZABLE cut off more and more anomalies.
Навіщо QA це знати. Такі баги не видно в послідовних тестах: кожен запит окремо правильний, ламається лише одночасність. У PostgreSQL за замовчуванням READ COMMITTED, а READ UNCOMMITTED фактично поводиться так само, тож брудного читання там немає. REPEATABLE READ у PostgreSQL — знімкова ізоляція, яка не пропускає фантомів, але пропускає write skew; від нього рятує лише SERIALIZABLE. У MySQL InnoDB за замовчуванням REPEATABLE READ, і поведінка відрізняється, тому рівень треба знати для конкретної бази.
Why QA needs it. Such bugs are invisible in sequential tests: each request alone is correct, only concurrency breaks things. PostgreSQL defaults to READ COMMITTED, and READ UNCOMMITTED actually behaves the same, so there are no dirty reads there. REPEATABLE READ in PostgreSQL is snapshot isolation, which blocks phantoms but still allows write skew; only SERIALIZABLE prevents it. MySQL InnoDB defaults to REPEATABLE READ with different behaviour, so you need to know the level for the specific database.
Як тестувати. Спершу відтворюю вручну двома сесіями psql, як у прикладі нижче, щоб довести механізм. Потім автоматизую: запускаю N паралельних запитів на ту саму сутність (Promise.all, k6 з кількома VU на один ресурс) і перевіряю інваріант: сума поповнень дорівнює приросту балансу, кількість проданого не перевищує залишку. Тест повторюю кілька десятків разів, бо гонка проявляється не щоразу.
How to test. First I reproduce it manually with two psql sessions, as in the example below, to prove the mechanism. Then I automate it: fire N concurrent requests at the same entity (Promise.all, k6 with several VUs on one resource) and check an invariant: the sum of top-ups equals the balance increase, the number sold does not exceed stock. I repeat the test dozens of times, because a race does not show up on every run.
Підводні камені. Підняти рівень до REPEATABLE READ чи SERIALIZABLE — не безкоштовне рішення: PostgreSQL почне відхиляти конфліктні транзакції з помилкою серіалізації, і застосунок мусить їх повторювати. Якщо ретраю немає, користувач отримає 500 замість втраченого оновлення. Тож після виправлення я тестую і цей шлях. Надійні виправлення lost update — атомарний UPDATE ... SET balance = balance + 50, SELECT ... FOR UPDATE або оптимістичне блокування з колонкою version.
Pitfalls. Raising the level to REPEATABLE READ or SERIALIZABLE is not free: PostgreSQL starts rejecting conflicting transactions with a serialization failure, and the application must retry them. Without a retry the user gets a 500 instead of a lost update. So after the fix I test that path too. Reliable fixes for a lost update are an atomic UPDATE ... SET balance = balance + 50, SELECT ... FOR UPDATE, or optimistic locking with a version column.
Уточнення. Класичний приклад write skew: у лікарні правило «хоча б один лікар на чергуванні», двоє одночасно знімаються з чергування, кожен бачить колегу на місці — і не лишається нікого.
Follow-up. The classic write-skew example: a hospital rule says "at least one doctor on call", two doctors go off call at the same time, each sees the other still on duty, and nobody is left.
-- сесія A -- сесія B
BEGIN; BEGIN;
SELECT balance FROM accounts
WHERE id = 1; -- 100
SELECT balance FROM accounts
WHERE id = 1; -- 100
-- застосунок рахує 100 + 50
UPDATE accounts SET balance = 150
WHERE id = 1;
-- застосунок рахує 100 + 30
UPDATE accounts SET balance = 130
WHERE id = 1; -- чекає на A
COMMIT;
COMMIT;
SELECT balance FROM accounts WHERE id = 1; -- 130, а мало бути 180
-- виправлення: атомарна зміна без читання в застосунку
UPDATE accounts SET balance = balance + 50 WHERE id = 1;
-- session A -- session B
BEGIN; BEGIN;
SELECT balance FROM accounts
WHERE id = 1; -- 100
SELECT balance FROM accounts
WHERE id = 1; -- 100
-- the app computes 100 + 50
UPDATE accounts SET balance = 150
WHERE id = 1;
-- the app computes 100 + 30
UPDATE accounts SET balance = 130
WHERE id = 1; -- waits for A
COMMIT;
COMMIT;
SELECT balance FROM accounts WHERE id = 1; -- 130, should be 180
-- the fix: an atomic change with no read in the app
UPDATE accounts SET balance = balance + 50 WHERE id = 1;
UPDATE сесії B блокується на рядку, доки A не закомітить, а потім перезаписує 150 своїм 130. На REPEATABLE READ сесія B отримала б помилку «could not serialize access due to concurrent update». Атомарний вираз balance + 50 читає актуальне значення вже під блокуванням рядка.UPDATE blocks on the row until A commits, then overwrites 150 with its 130. Under REPEATABLE READ session B would get the error "could not serialize access due to concurrent update". The atomic expression balance + 50 reads the current value while already holding the row lock.Що це. Індекс — додаткова структура (найчастіше B-tree), яка дозволяє знайти рядки без перегляду всієї таблиці; за це платять місцем і повільнішими INSERT та UPDATE. N+1 — патерн, коли код робить один запит за списком і ще по запиту на кожен елемент, замість одного JOIN чи IN. Пул з'єднань — набір заздалегідь відкритих з'єднань з базою (наприклад, HikariCP у Java), які сервіс позичає на час запиту, бо відкривати нове з'єднання дорого.
What it is. An index is an extra structure (most often a B-tree) that finds rows without scanning the whole table; you pay with space and slower INSERT and UPDATE. N+1 is the pattern where code runs one query for a list and then one more query per item, instead of a single JOIN or IN. A connection pool is a set of pre-opened database connections (for example HikariCP in Java) that the service borrows for the duration of a request, because opening a new connection is expensive.
Навіщо QA це знати. Усі три проблеми невидимі на тестовій базі з сотнею рядків і одним користувачем. Вони вибухають на продакшн-обсязі: запит без індексу по мільйону рядків, сторінка з двохсот позицій, що робить двісті один запит, або пул на десять з'єднань, який вичерпується під навантаженням, і запити чекають у черзі до таймауту. Функціонально все правильно, а сервіс лежить.
Why QA needs it. All three problems are invisible on a test database with a hundred rows and one user. They explode at production volume: a query without an index over a million rows, a page with two hundred items that runs two hundred and one queries, or a pool of ten connections that runs dry under load so requests queue until they time out. Functionally everything is correct, yet the service is down.
Як тестувати. Індекси: на базі з реалістичним обсягом запускаю EXPLAIN ANALYZE для ключових запитів і шукаю Seq Scan по великих таблицях. N+1: рахую кількість SQL-запитів на один API-виклик (pg_stat_statements, лог запитів, статистика ORM) для списку з 10 і зі 100 елементів; якщо кількість росте разом з розміром списку — це N+1. Пул: під навантаженням стежу за метриками активних і очікуючих з'єднань та за помилками на кшталт «Connection is not available, request timed out».
How to test. Indexes: on a database with realistic volume I run EXPLAIN ANALYZE for key queries and look for a Seq Scan on big tables. N+1: I count SQL queries per API call (pg_stat_statements, the query log, ORM statistics) for a list of 10 and of 100 items; if the count grows with the list size, it is N+1. Pool: under load I watch the active and pending connection metrics and errors like "Connection is not available, request timed out".
Підводні камені. Планувальник обирає план за статистикою, тому на крихітній тестовій таблиці Seq Scan може бути правильним вибором, а на продакшні той самий запит піде по індексу, і навпаки. Висновки роблю лише на даних схожого розміру й розподілу. Збільшити пул — не завжди рішення: у бази є ліміт max_connections, і двадцять подів по п'ятдесят з'єднань легко його перевищать. Витік з'єднання (не повернули в пул після винятку) виглядає як поступова деградація, тому тест на довгу тривалість корисніший за короткий пік.
Pitfalls. The planner picks a plan from statistics, so on a tiny test table a Seq Scan may be the right choice, while in production the same query uses an index, and vice versa. I only draw conclusions on data of similar size and distribution. A bigger pool is not always the answer: the database has a max_connections limit, and twenty pods with fifty connections each exceed it easily. A connection leak (not returned to the pool after an exception) looks like gradual degradation, so a long soak test is more useful than a short spike.
Уточнення. Можуть спитати, чи індексувати все підряд. Ні: кожен індекс сповільнює запис і займає пам'ять, тому індекс додають під конкретний запит, а QA перевіряє, що запис не став повільнішим.
Follow-up. You may be asked whether to index everything. No: every index slows down writes and uses memory, so an index is added for a specific query, and QA checks that writes did not get slower.
EXPLAIN ANALYZE
SELECT * FROM orders WHERE customer_id = 7;
-- Seq Scan on orders (rows=12 loops=1)
-- Filter: (customer_id = 7)
-- Rows Removed by Filter: 1999988
-- Execution Time: 180.4 ms
CREATE INDEX CONCURRENTLY idx_orders_customer
ON orders (customer_id);
-- Index Scan using idx_orders_customer on orders
-- Execution Time: 0.09 ms
-- N+1: скільки разів виконався запит позицій
SELECT calls, query FROM pg_stat_statements
WHERE query LIKE '%FROM order_items%';
EXPLAIN ANALYZE
SELECT * FROM orders WHERE customer_id = 7;
-- Seq Scan on orders (rows=12 loops=1)
-- Filter: (customer_id = 7)
-- Rows Removed by Filter: 1999988
-- Execution Time: 180.4 ms
CREATE INDEX CONCURRENTLY idx_orders_customer
ON orders (customer_id);
-- Index Scan using idx_orders_customer on orders
-- Execution Time: 0.09 ms
-- N+1: how many times the items query ran
SELECT calls, query FROM pg_stat_statements
WHERE query LIKE '%FROM order_items%';
EXPLAIN ANALYZE справді виконує запит і показує план з фактичним часом; рядок «Rows Removed by Filter» у Seq Scan каже, скільки рядків база переглянула марно. CONCURRENTLY будує індекс без блокування запису в таблицю. Представлення pg_stat_statements (розширення, яке треба ввімкнути) рахує виклики кожного нормалізованого запиту: якщо після одного відкриття сторінки calls зросло на кількість позицій — це N+1.EXPLAIN ANALYZE actually runs the query and shows the plan with real timings; the "Rows Removed by Filter" line of a Seq Scan says how many rows were read in vain. CONCURRENTLY builds the index without blocking writes to the table. The pg_stat_statements view (an extension that must be enabled) counts calls of every normalised query: if one page load raises calls by the number of items, that is N+1.Що це. Реплікація копіює зміни з primary на одну чи кілька реплік, щоб розвантажити читання і пережити падіння вузла. Найчастіше вона асинхронна: primary підтверджує запис, не чекаючи реплік, і репліка застосовує його трохи пізніше. Цей проміжок і є лагом реплікації — зазвичай мілісекунди, під навантаженням секунди й більше. Eventual consistency — гарантія, що без нових записів усі копії рано чи пізно зійдуться, але не обіцянка, коли саме.
What it is. Replication copies changes from the primary to one or more replicas to offload reads and survive a node failure. Most often it is asynchronous: the primary acknowledges a write without waiting for replicas, and the replica applies it a little later. That gap is replication lag, usually milliseconds, under load seconds or more. Eventual consistency guarantees that without new writes all copies will converge sooner or later, but it does not promise when.
Навіщо QA це знати. Типовий баг: користувач зберіг профіль, сторінка перезавантажилась і показала старе ім'я, бо запис пішов у primary, а читання — на репліку. Для користувача це виглядає як «не зберіглось», і він натискає ще раз. Системи вирішують це по-різному: читання власних змін з primary протягом кількох секунд після запису, sticky-сесія на primary, токен версії, який репліка має наздогнати. QA має знати, яку гарантію обрали, і тестувати саме її.
Why QA needs it. A typical bug: a user saves their profile, the page reloads and shows the old name, because the write went to the primary and the read to a replica. To the user it looks like "it didn't save", so they click again. Systems solve this differently: reading your own changes from the primary for a few seconds after a write, a sticky session on the primary, a version token the replica must catch up to. QA needs to know which guarantee was chosen and test exactly that.
Як тестувати. Read-after-write перевіряю одразу після запису, без пауз: POST, а потім негайний GET тим самим користувачем має повернути нові дані. Щоб баг не ховався за швидкою мережею, у тестовому середовищі штучно збільшую лаг: у PostgreSQL на репліці є параметр recovery_min_apply_delay. Для даних, які за дизайном узгоджуються з часом (пошуковий індекс, лічильники), замість sleep(5) використовую опитування з дедлайном: перевіряю умову кожні 200 мс до SLA, наприклад 10 секунд, і падаю з останнім побаченим значенням.
How to test. I check read-after-write right after the write, with no pause: a POST followed by an immediate GET from the same user must return the new data. So the bug cannot hide behind a fast network, I increase lag artificially in the test environment: a PostgreSQL replica has the recovery_min_apply_delay setting. For data that converges over time by design (a search index, counters), instead of sleep(5) I poll with a deadline: check the condition every 200 ms up to the SLA, say 10 seconds, and fail with the last value seen.
Підводні камені. Фіксований sleep одночасно і повільний, і ненадійний: у CI під навантаженням п'яти секунд не вистачить, а локально тест марно чекає. Друга пастка — перевіряти лише, що дані колись з'явились, і не перевіряти час збіжності: якщо в продакшні лаг хвилина, тест із дедлайном у годину нічого не доведе. Третя — моніторинг: лаг варто виводити метрикою й мати на нього алерт.
Pitfalls. A fixed sleep is both slow and unreliable: in CI under load five seconds is not enough, and locally the test waits for nothing. The second trap is checking only that data appeared eventually, not how long convergence took: if production lag is a minute, a test with a one-hour deadline proves nothing. The third is monitoring: lag should be exported as a metric with an alert on it.
Уточнення. Як виміряти лаг у PostgreSQL? На репліці SELECT now() - pg_last_xact_replay_timestamp() показує, наскільки давно застосована остання транзакція, а на primary представлення pg_stat_replication дає replay_lag.
Follow-up. How do you measure lag in PostgreSQL? On a replica, SELECT now() - pg_last_xact_replay_timestamp() shows how long ago the last transaction was applied, and on the primary the pg_stat_replication view gives replay_lag.
клієнт --POST /profile--> API --INSERT/UPDATE--> primary
|
асинхронно, лаг 0.5-3 с
v
клієнт --GET /profile---> API --SELECT---------> репліка
(ще старе ім'я)
client --POST /profile--> API --INSERT/UPDATE--> primary
|
async, lag 0.5-3 s
v
client --GET /profile---> API --SELECT---------> replica
(old name still)
async function eventually(check, { timeout = 10000, every = 200 } = {}) {
const deadline = Date.now() + timeout;
let last;
while (Date.now() < deadline) {
try { return await check(); } catch (e) { last = e; }
await new Promise(r => setTimeout(r, every));
}
throw new Error('не зійшлось за ' + timeout + ' мс: ' + last);
}
// профіль: читаємо одразу, без очікування
await api.put('/profile', { name: 'Olena' });
expect((await api.get('/profile')).name).toBe('Olena');
// пошук: узгоджується з часом, SLA 10 секунд
await eventually(async () => {
const hits = await api.get('/search?q=Olena');
expect(hits.length).toBe(1);
});
async function eventually(check, { timeout = 10000, every = 200 } = {}) {
const deadline = Date.now() + timeout;
let last;
while (Date.now() < deadline) {
try { return await check(); } catch (e) { last = e; }
await new Promise(r => setTimeout(r, every));
}
throw new Error('did not converge in ' + timeout + ' ms: ' + last);
}
// profile: read immediately, no waiting
await api.put('/profile', { name: 'Olena' });
expect((await api.get('/profile')).name).toBe('Olena');
// search: converges over time, SLA is 10 seconds
await eventually(async () => {
const hits = await api.get('/search?q=Olena');
expect(hits.length).toBe(1);
});
eventually повторює перевірку, доки вона не пройде або не вийде дедлайн, і запам'ятовує останню помилку, щоб звіт пояснював, що саме не зійшлось. Тест розділяє два види гарантій: профіль користувача має бути видно одразу (read-your-writes), тому там немає очікування, а пошук за дизайном оновлюється з часом, тому там дедлайн дорівнює обіцяному SLA.eventually repeats the check until it passes or the deadline expires, and keeps the last error so the report explains what did not converge. The test separates two kinds of guarantee: the user's profile must be visible at once (read-your-writes), so there is no waiting, while search is updated over time by design, so its deadline equals the promised SLA.Що це. Шардинг — горизонтальне розбиття даних між кількома незалежними базами. Кожен рядок потрапляє на шард за ключем шардування: хеш від user_id, діапазон дат, регіон. На відміну від реплікації, де кожен вузол має повну копію, тут кожен шард зберігає лише свою частину, тож масштабується і запис, і обсяг. Ціна — складність: запити, транзакції й унікальність, які охоплюють кілька шардів, стають дорогими або неможливими.
What it is. Sharding is horizontal splitting of data across several independent databases. Each row lands on a shard by the shard key: a hash of user_id, a date range, a region. Unlike replication, where each node has a full copy, each shard stores only its own part, so both writes and volume scale. The price is complexity: queries, transactions and uniqueness that span several shards become expensive or impossible.
Навіщо QA це знати. Логіка, яка на одній базі була тривіальною, на шардах ламається. Звіт «усі замовлення за день» тепер збирає дані з усіх шардів і зливає їх; пагінація з сортуванням по часу мусить враховувати кожен шард; перевірка «email унікальний» працює лише в межах шарду, якщо ключ — user_id. Окремий ризик — гарячий шард: великий клієнт чи знаменитість генерує стільки трафіку, що один вузол перевантажений, а решта простоюють.
Why QA needs it. Logic that was trivial on one database breaks on shards. A report of "all orders for the day" now gathers data from every shard and merges it; pagination sorted by time must account for each shard; a "unique email" check only works within a shard if the key is user_id. A separate risk is a hot shard: a big customer or a celebrity generates so much traffic that one node is overloaded while the rest sit idle.
Як тестувати. Тестові дані навмисно розкладаю по різних шардах: кілька користувачів, чиї ключі гарантовано потрапляють на різні вузли. Далі перевіряю операції, що перетинають межу: переказ між користувачами з різних шардів, глобальний пошук, звіт, пагінацію без дублів і пропусків на стику сторінок. Для навантаження будую перекошений профіль, де 20% ключів дають 80% запитів, і дивлюсь на метрики кожного шарду окремо. Перед решардингом тестую міграцію: кількість і контрольні суми рядків до і після збігаються.
How to test. I deliberately spread test data across shards: several users whose keys are guaranteed to land on different nodes. Then I check operations that cross the boundary: a transfer between users on different shards, global search, a report, pagination with no duplicates or gaps at page edges. For load I build a skewed profile where 20% of keys produce 80% of requests and watch each shard's metrics separately. Before resharding I test the migration: row counts and checksums before and after match.
Підводні камені. Розподіл за формулою hash(key) % N ламається при зміні N: майже всі ключі переїжджають на інші шарди, тому використовують consistent hashing або віртуальні шарди з таблицею відповідності. Автоінкрементні id перестають бути глобально унікальними — потрібні UUID, Snowflake-подібні id або окремі діапазони. Тест, який створює всі дані одним користувачем, покриває лише один шард і нічого з цього не побачить.
Pitfalls. Distribution by hash(key) % N breaks when N changes: almost every key moves to another shard, which is why consistent hashing or virtual shards with a lookup table are used. Auto-increment ids stop being globally unique, so you need UUIDs, Snowflake-style ids or separate ranges. A test that creates all its data under one user covers a single shard and will see none of this.
Уточнення. Як обрати ключ? Той, за яким ідуть найчастіші запити і який розподіляється рівномірно. Для чату це часто conversation_id, а не дата: за датою весь сьогоднішній запис піде на один шард.
Follow-up. How do you pick the key? The one most frequent queries use and which spreads evenly. For a chat it is often conversation_id rather than the date: by date, all of today's writes would go to one shard.
import hashlib
SHARDS = 4
def shard_for(user_id):
h = int(hashlib.md5(str(user_id).encode()).hexdigest(), 16)
return h % SHARDS
# тестові дані: по два користувачі на кожен шард
users = {}
for uid in range(1, 200):
users.setdefault(shard_for(uid), []).append(uid)
test_users = [ids[:2] for ids in users.values()]
assert len(test_users) == SHARDS # покрито всі шарди
# перекіс: частка рядків на кожному шарді
# SELECT COUNT(*) FROM orders; -- на кожному шарді окремо
# тривога, якщо один шард має більше 40% від суми
import hashlib
SHARDS = 4
def shard_for(user_id):
h = int(hashlib.md5(str(user_id).encode()).hexdigest(), 16)
return h % SHARDS
# test data: two users for every shard
users = {}
for uid in range(1, 200):
users.setdefault(shard_for(uid), []).append(uid)
test_users = [ids[:2] for ids in users.values()]
assert len(test_users) == SHARDS # every shard is covered
# skew: share of rows on each shard
# SELECT COUNT(*) FROM orders; -- on every shard separately
# alert if one shard holds more than 40% of the total
shard_for повторює правило маршрутизації сервісу: стабільний хеш (MD5, а не вбудований hash() Python, який для рядків змінюється між запусками) за модулем кількості шардів. Цикл підбирає по два user_id на кожен шард, тож подальші тести гарантовано зачіпають усі вузли, а assert падає, якщо якийсь шард лишився без даних. Коментар унизу — перевірка перекосу, яку запускають на кожному шарді.shard_for mirrors the service's routing rule: a stable hash (MD5, not Python's built-in hash(), which changes between runs for strings) modulo the number of shards. The loop picks two user_ids per shard, so later tests are guaranteed to touch every node, and the assert fails if a shard is left without data. The comment at the bottom is the skew check run on each shard.Що це. Популярне формулювання «з трьох властивостей обирай дві» — спрощення, яке вводить в оману. Точніше так: якщо мережа розділилась (partition) і вузли не бачать одне одного, система мусить обрати між Consistency — кожне читання бачить останній запис або отримує помилку, — і Availability — кожен живий вузол відповідає, навіть якщо дані можуть бути застарілими. Розділ мережі в розподіленій системі не вибирають, він просто стається. Поки мережа ціла, система може бути і узгодженою, і доступною.
What it is. The popular wording "pick two of three" is a misleading simplification. More precisely: if the network is partitioned and nodes cannot see each other, the system must choose between Consistency, where every read sees the latest write or gets an error, and Availability, where every live node answers even if the data may be stale. A distributed system does not get to opt out of partitions; they simply happen. While the network is healthy, a system can be both consistent and available.
Навіщо QA це знати. Від вибору залежить, що вважати багом. CP-система (etcd, ZooKeeper, кластер на Raft) на меншій стороні розділу відмовляє в записі: помилка чи таймаут — очікувана поведінка, а не дефект. AP-система (Cassandra з рівнем ONE, DNS, кошик у стилі Dynamo) продовжує приймати записи на обох сторонах, а після з'єднання мусить розв'язати конфлікти. Багато баз налаштовуються: Cassandra з QUORUM поводиться ближче до CP. Розширення PACELC додає: навіть без розділу є компроміс між затримкою і узгодженістю.
Why QA needs it. The choice decides what counts as a bug. A CP system (etcd, ZooKeeper, a Raft cluster) refuses writes on the minority side of a partition: an error or timeout is expected behaviour, not a defect. An AP system (Cassandra at consistency ONE, DNS, a Dynamo-style shopping cart) keeps accepting writes on both sides and must resolve conflicts after reconnecting. Many databases are tunable: Cassandra with QUORUM behaves closer to CP. The PACELC extension adds that even without a partition there is a trade-off between latency and consistency.
Як тестувати. Спершу з'ясовую, яку поведінку обрала команда для кожної операції, і записую її як очікуваний результат. Потім у тестовому середовищі створюю розділ: docker network disconnect для вузла, правила iptables або Toxiproxy між сервісом і сховищем. Під час розділу перевіряю: CP-операції повертають зрозумілу помилку, а не зависають; AP-операції відповідають, і система позначає дані як можливо застарілі. Після відновлення перевіряю збіжність: жоден підтверджений запис не загубився, конфлікти розв'язані за задокументованим правилом.
How to test. First I find out which behaviour the team chose for each operation and write it down as the expected result. Then I create a partition in the test environment: docker network disconnect for a node, iptables rules, or Toxiproxy between the service and its store. During the partition I check that CP operations return a clear error rather than hanging, and AP operations answer while the system marks data as possibly stale. After healing I check convergence: no acknowledged write was lost, and conflicts were resolved by the documented rule.
Підводні камені. Правило «last write wins» за годинником вузлів тихо губить записи, бо годинники розходяться; тест із двома конкурентними записами на різних сторонах розділу це показує. Друга пастка — вважати таймаут чистою відмовою: запис міг застосуватись, а відповідь загубилась, тож клієнт повторить його. Третя — ганяти такі досліди в спільному staging без попередження: розділ зачепить чужі тести.
Pitfalls. A "last write wins" rule based on node clocks silently loses writes, because clocks drift; a test with two concurrent writes on opposite sides of the partition shows it. The second trap is treating a timeout as a clean failure: the write may have applied while the response was lost, so the client will retry it. The third is running such experiments in a shared staging environment without warning: the partition will hit other people's tests.
Уточнення. Що означає C у CAP? Лінеаризованість: операції виглядають так, ніби виконуються по одній на єдиній копії. Це не C з ACID, яке стосується обмежень схеми.
Follow-up. What does C in CAP mean? Linearizability: operations look as if they run one at a time on a single copy. That is not the C in ACID, which is about schema constraints.
# проксі між сервісом і Redis
toxiproxy-cli create -l 0.0.0.0:26379 -u redis:6379 redis
# розділ: дані не проходять, з'єднання висять
toxiproxy-cli toxic add -t timeout -a timeout=0 redis
# ... запускаємо тести під час розділу ...
toxiproxy-cli toxic remove -n timeout_downstream redis
# вузол кластера бази випадає з мережі
docker network disconnect testnet db-node-3
docker network connect testnet db-node-3
# a proxy between the service and Redis
toxiproxy-cli create -l 0.0.0.0:26379 -u redis:6379 redis
# partition: no data flows, connections hang
toxiproxy-cli toxic add -t timeout -a timeout=0 redis
# ... run the tests during the partition ...
toxiproxy-cli toxic remove -n timeout_downstream redis
# a database cluster node drops off the network
docker network disconnect testnet db-node-3
docker network connect testnet db-node-3
Операція Вибір Під час розділу Після відновлення
списання з рахунку CP 503 за 2 с, без запису нічого не губиться
додати в кошик AP 200, запис локально кошики злиті
лічильник переглядів AP 200, неточне число число наздогнало
Operation Choice During the partition After healing
debit an account CP 503 within 2 s, no write nothing is lost
add to cart AP 200, local write carts merged
view counter AP 200, approximate value value caught up
timeout зі значенням 0 затримує дані без закриття з'єднання, що схоже на справжній розділ, а не на акуратну відмову. Ім'я токсика за замовчуванням складається з типу й напрямку, звідси timeout_downstream. Таблиця нижче — оракул: для кожної операції записано обраний бік CAP і що саме тест очікує під час і після розділу.timeout toxic with value 0 holds data without closing the connection, which resembles a real partition rather than a tidy refusal. The default toxic name is its type plus direction, hence timeout_downstream. The table below is the oracle: for each operation it records the chosen side of CAP and what the test expects during and after the partition.Що це. Міграція — версіонований скрипт зміни схеми або даних, який інструмент на кшталт Flyway чи Liquibase застосовує по порядку й записує в службову таблицю історії. Expand/contract (його ще називають parallel change) — спосіб робити несумісні зміни без простою: спершу розширити схему так, щоб працювала і стара, і нова версія коду, перенести дані, перемкнути код, і лише в пізнішому релізі прибрати старе.
What it is. A migration is a versioned script that changes the schema or data; a tool such as Flyway or Liquibase applies them in order and records them in a history table. Expand/contract (also called parallel change) is a way to make incompatible changes without downtime: first expand the schema so both the old and the new code version work, move the data, switch the code, and only in a later release remove the old parts.
Навіщо QA це знати. Під час rolling-деплою чи canary старі й нові поди працюють одночасно з однією базою. Якщо міграція перейменувала колонку одним кроком, старі поди одразу почнуть падати. Друга група ризиків — блокування: ALTER TABLE чи створення індексу без CONCURRENTLY на великій таблиці може тримати блокування хвилинами, і всі запити до таблиці стануть у чергу. Третя — дані: бекфіл пропустив рядки або неправильно перетворив значення.
Why QA needs it. During a rolling deploy or canary, old and new pods run against the same database at the same time. If a migration renamed a column in one step, the old pods start failing immediately. The second group of risks is locking: an ALTER TABLE or an index built without CONCURRENTLY on a big table can hold a lock for minutes, queueing every query to that table. The third is data: the backfill skipped rows or transformed values incorrectly.
Як тестувати. Прогоняю міграцію на знеособленій копії продакшн-бази схожого розміру й вимірюю час та блокування. Після бекфілу запускаю контрольні SQL-запити: кількість рядків збігається, у новій колонці немає несподіваних NULL, значення відповідають старим за правилом перетворення. Регресійні тести запускаю двічі: стара версія коду на новій схемі і нова версія на новій схемі. Окремо перевіряю, що міграція ідемпотентна для перезапуску після збою і що в CI кожна міграція проходить на чистій базі з нуля.
How to test. I run the migration on an anonymised copy of production of similar size and measure time and locks. After the backfill I run control SQL queries: row counts match, the new column has no unexpected NULLs, values correspond to the old ones by the transformation rule. I run the regression suite twice: the old code version on the new schema and the new version on the new schema. I also check that the migration can be safely rerun after a failure and that in CI every migration applies to an empty database from scratch.
Підводні камені. У PostgreSQL додавання колонки з константним DEFAULT з версії 11 миттєве, а от додавання NOT NULL до наявної колонки сканує всю таблицю під блокуванням; безпечніше додати CHECK з NOT VALID і окремо виконати VALIDATE CONSTRAINT. CREATE INDEX CONCURRENTLY не можна запускати всередині транзакції, а Flyway за замовчуванням загортає міграцію в транзакцію. І відкат: після contract-кроку старі дані вже видалені, тож «down»-міграція часто неможлива, і план відкату треба тестувати до релізу.
Pitfalls. In PostgreSQL, adding a column with a constant DEFAULT has been instant since version 11, but adding NOT NULL to an existing column scans the whole table under a lock; it is safer to add a CHECK with NOT VALID and run VALIDATE CONSTRAINT separately. CREATE INDEX CONCURRENTLY cannot run inside a transaction, while Flyway wraps a migration in one by default. And rollback: after the contract step the old data is gone, so a "down" migration is often impossible, and the rollback plan must be tested before release.
Уточнення. Скільки релізів займає перейменування колонки через expand/contract? Зазвичай три: розширити й писати в обидві колонки, перемкнути читання, прибрати стару колонку.
Follow-up. How many releases does renaming a column via expand/contract take? Usually three: expand and write to both columns, switch reads, drop the old column.
-- реліз 1 (expand): нова колонка, код пише в обидві
ALTER TABLE users ADD COLUMN display_name text;
UPDATE users SET display_name = full_name
WHERE id BETWEEN 1 AND 50000 AND display_name IS NULL;
-- ... бекфіл пачками далі ...
-- перевірка після бекфілу: обидва числа мають бути 0
SELECT COUNT(*) FROM users WHERE display_name IS NULL;
SELECT COUNT(*) FROM users
WHERE display_name IS DISTINCT FROM full_name;
-- безпечний NOT NULL без довгого блокування
ALTER TABLE users ADD CONSTRAINT display_name_nn
CHECK (display_name IS NOT NULL) NOT VALID;
ALTER TABLE users VALIDATE CONSTRAINT display_name_nn;
-- реліз 3 (contract): лише коли жоден под не читає full_name
ALTER TABLE users DROP COLUMN full_name;
-- release 1 (expand): new column, code writes to both
ALTER TABLE users ADD COLUMN display_name text;
UPDATE users SET display_name = full_name
WHERE id BETWEEN 1 AND 50000 AND display_name IS NULL;
-- ... backfill in further batches ...
-- after the backfill: both numbers must be 0
SELECT COUNT(*) FROM users WHERE display_name IS NULL;
SELECT COUNT(*) FROM users
WHERE display_name IS DISTINCT FROM full_name;
-- a safe NOT NULL without a long lock
ALTER TABLE users ADD CONSTRAINT display_name_nn
CHECK (display_name IS NOT NULL) NOT VALID;
ALTER TABLE users VALIDATE CONSTRAINT display_name_nn;
-- release 3 (contract): only when no pod reads full_name
ALTER TABLE users DROP COLUMN full_name;
display_name IS NULL робить повторний запуск безпечним. IS DISTINCT FROM порівнює з урахуванням NULL, тому ловить і розбіжні значення, і пропуски. NOT VALID додає обмеження лише для нових рядків без сканування, а VALIDATE CONSTRAINT перевіряє старі рядки під слабшим блокуванням, яке не зупиняє запис.display_name IS NULL condition makes a rerun safe. IS DISTINCT FROM compares with NULL awareness, so it catches both mismatched values and gaps. NOT VALID adds the constraint for new rows only, without a scan, and VALIDATE CONSTRAINT checks old rows under a weaker lock that does not stop writes.Контекст і ризики. Типовий checkout: кошик рахує ціну, сервіс складу резервує товар на 15 хвилин, платіжний сервіс списує гроші, сервіс замовлень підтверджує, а подія йде на доставку. Головні ризики: overselling, коли останню одиницю продали двом покупцям; гроші списані, а замовлення не створене; резерв завис назавжди після кинутого кошика; ціна змінилась між кошиком і оплатою; подвійне замовлення після подвійного кліку чи ретраю мобільного клієнта.
Context and risks. A typical checkout: the cart computes the price, the inventory service reserves stock for 15 minutes, the payment service charges the money, the order service confirms, and an event goes to shipping. Main risks: overselling, when the last unit is sold to two buyers; money charged but no order created; a reservation stuck forever after an abandoned cart; the price changing between cart and payment; a duplicate order after a double click or a mobile client retry.
Стратегія по рівнях. Юніт-тести на ціноутворення: знижки, податки, округлення валют. Компонентні тести сервісу складу на справжній базі в Testcontainers для конкурентного резервування. Контрактні тести Pact між checkout, складом і платежами. Інтеграційні тести саги з віртуалізованим платіжним провайдером, який уміє відповідати успіхом, відмовою, таймаутом і повільною відповіддю. Кілька E2E-сценаріїв через UI для щасливого шляху, а навантажувальний тест — під розпродаж, де тисячі покупців б'ють в один SKU.
Strategy by layer. Unit tests for pricing: discounts, taxes, currency rounding. Component tests of the inventory service on a real database in Testcontainers for concurrent reservation. Pact contract tests between checkout, inventory and payments. Integration tests of the saga with a virtualised payment provider that can answer with success, decline, timeout and a slow response. A few UI E2E scenarios for the happy path, and a load test shaped like a flash sale where thousands of buyers hit one SKU.
Ключові тест-кейси. Сто паралельних checkout на товар із залишком 10: рівно 10 успішних замовлень, 90 чітких відмов, залишок 0, а не мінус. Відмова платежу: резерв знято, залишок повернувся. Таймаут платежу з невідомим результатом: замовлення в стані «очікує», звірка з провайдером пізніше доводить його до кінця, гроші не списані двічі. Протермінований резерв звільняється фоновою задачею. Зміна ціни під час оформлення: користувач бачить нову ціну до оплати. Повторний submit з тим самим Idempotency-Key дає одне замовлення.
Key test cases. A hundred concurrent checkouts for an item with stock 10: exactly 10 successful orders, 90 clear rejections, stock at 0, not negative. Payment declined: the reservation is released and stock returns. A payment timeout with an unknown outcome: the order is pending, and reconciliation with the provider later settles it with no double charge. An expired reservation is freed by a background job. A price change during checkout: the user sees the new price before paying. A repeated submit with the same Idempotency-Key yields one order.
Підводні камені. Тест overselling з двома запитами майже завжди зелений: гонку видно лише на десятках паралельних запитів і багатьох прогонах. Перевіряти треба інваріант у базі, а не лише HTTP-коди. Кеш залишків на сторінці товару за дизайном може показувати «є в наявності», хоча резерв уже неможливий, тож джерелом правди в тесті є лише сервіс складу. І тестові дані: SKU для навантаження мають бути окремими, щоб не ламати чужі тести.
Pitfalls. An overselling test with two requests is almost always green: the race only shows with dozens of concurrent requests and many runs. The invariant must be checked in the database, not only through HTTP codes. The stock cache on the product page may by design say "in stock" when a reservation is no longer possible, so the only source of truth in the test is the inventory service. And test data: SKUs for load tests must be separate so they do not break other people's tests.
Уточнення. Інтерв'юер може спитати, що робити, якщо гроші списано, а склад уже порожній. Правильна відповідь — компенсація саги: автоматичне повернення коштів і повідомлення клієнту, і саме цей шлях треба покрити тестом, бо він рідкісний і дорогий.
Follow-up. The interviewer may ask what happens if money was charged but stock is already gone. The right answer is a saga compensation: an automatic refund and a message to the customer, and that path needs a test precisely because it is rare and expensive.
Кошик --> Checkout API --(1) reserve--> Склад [резерв 15 хв]
|
+--(2) charge--> Платежі --> провайдер
| x таймаут: результат невідомий
+--(3) confirm--> Замовлення --event--> Доставка
Збій на (2): зняти резерв (компенсація)
Збій на (3): повернути гроші, зняти резерв
Cart --> Checkout API --(1) reserve--> Inventory [hold 15 min]
|
+--(2) charge--> Payments --> provider
| x timeout: outcome unknown
+--(3) confirm--> Orders --event--> Shipping
Failure at (2): release the hold (compensation)
Failure at (3): refund the money, release the hold
test('100 покупців на 10 одиниць: жодного зайвого продажу', async () => {
await inventory.setStock('SKU-LOAD-1', 10);
const results = await Promise.all(
Array.from({ length: 100 }, (_, i) =>
checkout(buyer(i), 'SKU-LOAD-1', { idempotencyKey: 'k-' + i })));
const ok = results.filter(r => r.status === 201).length;
expect(ok).toBe(10);
expect(results.filter(r => r.status === 409).length).toBe(90);
expect(await inventory.stock('SKU-LOAD-1')).toBe(0);
expect(await orders.count('SKU-LOAD-1')).toBe(10);
});
test('100 buyers for 10 units: nothing oversold', async () => {
await inventory.setStock('SKU-LOAD-1', 10);
const results = await Promise.all(
Array.from({ length: 100 }, (_, i) =>
checkout(buyer(i), 'SKU-LOAD-1', { idempotencyKey: 'k-' + i })));
const ok = results.filter(r => r.status === 201).length;
expect(ok).toBe(10);
expect(results.filter(r => r.status === 409).length).toBe(90);
expect(await inventory.stock('SKU-LOAD-1')).toBe(0);
expect(await orders.count('SKU-LOAD-1')).toBe(10);
});
Promise.all запускає сто оформлень одночасно, кожне зі своїм ключем ідемпотентності, щоб це були різні покупки, а не ретраї. Перевірка йде по трьох джерелах: HTTP-коди (10 разів 201 і 90 разів 409), залишок у сервісі складу і кількість замовлень у базі, бо саме розбіжність між ними і є overselling.Promise.all fires a hundred checkouts at once, each with its own idempotency key so they are distinct purchases rather than retries. The check uses three sources: HTTP codes (201 ten times and 409 ninety times), the stock in the inventory service, and the order count in the database, because a mismatch between them is what overselling is.Що це. Черга повідомлень — це буфер між двома сервісами: продюсер кладе повідомлення й одразу йде далі, а споживач забирає його, коли готовий. У моделі point-to-point кожне повідомлення з черги обробляє рівно один із споживачів, тож кілька екземплярів ділять роботу між собою. У моделі pub/sub видавець публікує подію в топік чи exchange, і кожен підписник отримує власну копію: сервіс листів, аналітика й склад реагують на «замовлення створено» незалежно, не знаючи один про одного.
What it is. A message queue is a buffer between two services: the producer drops a message and moves on, and the consumer picks it up when ready. In the point-to-point model each message in a queue is handled by exactly one consumer, so several instances share the work. In the pub/sub model a publisher sends an event to a topic or exchange and every subscriber gets its own copy: the email service, analytics and the warehouse each react to "order created" independently, without knowing about each other.
Навіщо. Асинхронність розв’язує сервіси в часі: якщо сервіс листів лежить десять хвилин, замовлення все одно приймаються, а листи підуть пізніше. Черга також згладжує піки навантаження й дозволяє масштабувати споживачів окремо. Ціна — відповідь користувачу вже не означає, що вся робота виконана, і з’являються нові класи багів: повідомлення загубилось, прийшло двічі, прийшло не в тому порядку чи застрягло.
Why. Asynchrony decouples services in time: if the email service is down for ten minutes, orders are still accepted and emails go out later. A queue also smooths load peaks and lets consumers scale on their own. The price is that a response to the user no longer means all the work is done, and new bug classes appear: a message is lost, arrives twice, arrives out of order or gets stuck.
Як тестувати. Я розбиваю перевірку на три частини. Продюсер: після дії в топіку з’являється рівно одна подія з правильною схемою, ключем і заголовками. Споживач: з поданого в чергу повідомлення виникає очікуваний побічний ефект у базі чи зовнішньому виклику. Наскрізний потік: від API до кінцевого стану, з очікуванням через опитування з дедлайном, а не через фіксований sleep. Окремо дивлюсь на метрики: глибину черги чи consumer lag, кількість повторів і вміст DLQ після прогону.
How to test. I split the check into three parts. Producer: after an action exactly one event appears in the topic with the right schema, key and headers. Consumer: a message fed into the queue produces the expected side effect in the database or an outbound call. End-to-end flow: from the API to the final state, waiting by polling with a deadline rather than a fixed sleep. Separately I watch the metrics: queue depth or consumer lag, the retry count and what is in the DLQ after the run.
Підводні камені. Тест, який перевіряє лише відповідь 202 Accepted, нічого не каже про подальшу обробку. Спільний тестовий брокер, у який пишуть паралельні прогони, дає «чужі» повідомлення в асертах, тому варто мати унікальний correlation id у кожному тесті й фільтрувати за ним. І не плутай черги з pub/sub: якщо два сервіси випадково читають з однієї черги, кожен бачить лише половину подій, і баг проявляється як «іноді лист не приходить».
Pitfalls. A test that only checks a 202 Accepted says nothing about what happens next. A shared test broker written to by parallel runs puts "foreign" messages into your assertions, so each test needs a unique correlation id to filter on. And don’t confuse queues with pub/sub: if two services accidentally read from the same queue, each sees only half the events, and the bug shows up as "sometimes the email never arrives".
Уточнення. Часто питають, коли черга не потрібна. Якщо користувач мусить одразу побачити результат, наприклад списання грошей до показу чека, синхронний виклик простіший і чесніший. Черга виправдана там, де робота може зачекати або де на одну подію реагує кілька незалежних споживачів.
Follow-up. A common question is when a queue is not needed. If the user must see the result immediately, for example the charge before the receipt is shown, a synchronous call is simpler and more honest. A queue pays off where work can wait or where several independent consumers react to one event.
Point-to-point (одна черга, конкуруючі споживачі)
Order API --> [ q.emails ] --> email-worker #1 msg 1, 3
\-> email-worker #2 msg 2, 4
кожне повідомлення обробляє ОДИН споживач
Pub/sub (топік або fanout exchange)
/--> emails (своя копія)
Order API --> order.created --> analytics (своя копія)
\--> warehouse (своя копія)
кожен підписник отримує ВСІ події
Що перевіряю: подія є і одна | побічний ефект є |
lag не росте | DLQ порожня після прогону
Point-to-point (one queue, competing consumers)
Order API --> [ q.emails ] --> email-worker #1 msg 1, 3
\-> email-worker #2 msg 2, 4
each message is handled by ONE consumer
Pub/sub (topic or fanout exchange)
/--> emails (own copy)
Order API --> order.created --> analytics (own copy)
\--> warehouse (own copy)
every subscriber gets ALL events
What I check: event exists, exactly once | side effect exists |
lag does not grow | DLQ is empty after the run
Що це. Kafka — це розподілений журнал: повідомлення дописуються в кінець партиції й зберігаються згідно з retention (за часом чи розміром) незалежно від того, прочитав їх хтось чи ні. Кожна consumer group сама пам’ятає свій offset і може перечитати історію. RabbitMQ — класичний брокер: продюсер шле в exchange, той за routing key кладе повідомлення в черги, брокер штовхає їх споживачам і видаляє після ack. Потоки (streams) у RabbitMQ з версії 3.9 дають журнальну модель, але типова класична чи quorum-черга після підтвердження повідомлення не зберігає.
What it is. Kafka is a distributed log: messages are appended to the end of a partition and kept according to retention (by time or size) whether or not anyone has read them. Each consumer group tracks its own offset and can reread history. RabbitMQ is a classic broker: the producer sends to an exchange, which routes messages into queues by routing key, the broker pushes them to consumers and deletes them after the ack. RabbitMQ streams, available since 3.9, give a log-like model, but a typical classic or quorum queue does not keep a message once it is acknowledged.
Навіщо QA ця різниця. Від моделі залежить, як відтворити баг і як дістати доказ. У Kafka я можу прочитати топік з початку й подивитись, що саме опублікував сервіс учора, а також перемотати offset групи і повторно прогнати обробку. У RabbitMQ оброблене повідомлення вже зникло, тож доказ треба ловити заздалегідь: тимчасовою чергою, прив’язаною до того самого exchange, або через логи споживача. Масштабування теж різне: у Kafka паралелізм групи обмежений кількістю партицій, у RabbitMQ — просто кількістю споживачів черги.
Why QA cares. The model decides how you reproduce a bug and how you get evidence. In Kafka I can read a topic from the beginning and see exactly what a service published yesterday, and I can rewind a group’s offset to rerun processing. In RabbitMQ a processed message is already gone, so evidence has to be captured up front: with a temporary queue bound to the same exchange, or through consumer logs. Scaling differs too: in Kafka a group’s parallelism is capped by the number of partitions, in RabbitMQ it is simply the number of consumers on the queue.
Як тестувати. Для Kafka головна метрика — consumer lag: різниця між останнім offset партиції й закомітленим offset групи. Під навантаженням lag має стабілізуватись, а не рости безкінечно. Я перевіряю ребаланс (додали чи вбили екземпляр — повідомлення не губляться й не дублюються понад допустиме) і replay (скинули offset — обробка ідемпотентна). Для RabbitMQ дивлюсь на глибину черги, кількість unacked, prefetch і поведінку при nack з requeue, бо невдалий requeue може зациклити повідомлення.
How to test. For Kafka the key metric is consumer lag: the gap between a partition’s latest offset and the group’s committed offset. Under load, lag should level off rather than grow forever. I check rebalancing (an instance added or killed — messages are neither lost nor duplicated beyond what is allowed) and replay (offset reset — processing is idempotent). For RabbitMQ I watch queue depth, the unacked count, prefetch, and behaviour on nack with requeue, because a careless requeue can loop a message forever.
Підводні камені. Скидання offset працює лише для неактивної групи, тож під час тесту споживачів треба зупинити. Retention у тестовому середовищі буває коротким, і «вчорашніх» подій уже немає. У RabbitMQ повідомлення, опубліковане в exchange без жодної прив’язаної черги, мовчки зникає, якщо продюсер не використовує прапорець mandatory чи alternate exchange, і тест на продюсера це легко пропускає.
Pitfalls. An offset reset only works on an inactive group, so consumers must be stopped during the test. Retention in a test environment is often short, and "yesterday’s" events are already gone. In RabbitMQ a message published to an exchange with no bound queue silently disappears unless the producer uses the mandatory flag or an alternate exchange, and a producer test easily misses that.
Уточнення. Якщо питають, що обрати, я відповідаю через вимоги: потрібна історія подій, replay і великий потік — Kafka; потрібна гнучка маршрутизація, пріоритети й класичні робочі черги задач — RabbitMQ. Для тестувальника важливіше не вибір, а те, які гарантії з нього випливають.
Follow-up. If asked which to choose, I answer through requirements: event history, replay and high throughput point to Kafka; flexible routing, priorities and classic task queues point to RabbitMQ. For a tester the choice matters less than the guarantees that follow from it.
# lag групи: LOG-END-OFFSET мінус CURRENT-OFFSET по кожній партиції
kafka-consumer-groups.sh --bootstrap-server localhost:9092 \
--describe --group billing-service
# що насправді опублікував сервіс, разом із ключами
kafka-console-consumer.sh --bootstrap-server localhost:9092 \
--topic orders --from-beginning --property print.key=true
# replay: спершу зупинити споживачів, потім перемотати offset
kafka-consumer-groups.sh --bootstrap-server localhost:9092 \
--group billing-service --topic orders \
--reset-offsets --to-earliest --execute
# group lag: LOG-END-OFFSET minus CURRENT-OFFSET per partition
kafka-consumer-groups.sh --bootstrap-server localhost:9092 \
--describe --group billing-service
# what the service really published, including keys
kafka-console-consumer.sh --bootstrap-server localhost:9092 \
--topic orders --from-beginning --property print.key=true
# replay: stop the consumers first, then rewind the offset
kafka-consumer-groups.sh --bootstrap-server localhost:9092 \
--group billing-service --topic orders \
--reset-offsets --to-earliest --execute
--describe друкує для кожної партиції CURRENT-OFFSET, LOG-END-OFFSET і LAG. --from-beginning читає топік з найстарішого збереженого повідомлення, а print.key=true показує ключ, від якого залежить партиція. --reset-offsets без --execute лише показує план, з ним — реально переписує offset групи.--describe prints CURRENT-OFFSET, LOG-END-OFFSET and LAG for each partition. --from-beginning reads the topic from the oldest retained message, and print.key=true shows the key that decides the partition. --reset-offsets without --execute only shows the plan; with it, the group’s offset is actually rewritten.Що це. At-most-once: повідомлення доставляється не більше одного разу, але може загубитись — так буває, коли споживач комітить offset чи ack до обробки й падає посередині. At-least-once: повідомлення не губиться, але може прийти двічі — ack іде після обробки, і падіння між ними дає повторну доставку. Exactly-once у чистому вигляді між брокером і зовнішнім світом недосяжний; Kafka дає ідемпотентного продюсера й транзакції для ланцюжка «прочитав з Kafka — записав у Kafka», а для бази, листа чи платежу на практиці будують effectively-once: at-least-once плюс ідемпотентний споживач.
What it is. At-most-once: a message is delivered no more than once but can be lost — this happens when a consumer commits the offset or acks before processing and crashes midway. At-least-once: a message is not lost but can arrive twice — the ack comes after processing, and a crash in between causes redelivery. Pure exactly-once between a broker and the outside world is not achievable; Kafka offers an idempotent producer and transactions for a "read from Kafka, write to Kafka" chain, while for a database, an email or a payment people build effectively-once in practice: at-least-once plus an idempotent consumer.
Навіщо. Більшість систем обирає at-least-once, бо втрата замовлення гірша за дубль. Отже дублікати — не рідкісна аварія, а нормальний режим роботи: їх дають ребаланс групи, рестарт поду, ретрай продюсера після таймауту. Якщо споживач не ідемпотентний, клієнт отримає два листи, склад двічі спише товар, а бонуси нарахуються двічі.
Why. Most systems choose at-least-once, because losing an order is worse than a duplicate. So duplicates are not a rare accident but normal operation: they come from a group rebalance, a pod restart or a producer retry after a timeout. If the consumer is not idempotent, the customer gets two emails, the warehouse writes off stock twice and loyalty points are credited twice.
Як тестувати. Найпростіший тест: опублікувати ту саму подію з тим самим event id двічі й перевірити, що побічний ефект стався один раз. Складніший і чесніший: зупинити споживача після запису в базу, але до коміту offset (через fault injection чи kill -9 у потрібний момент), підняти його знову й переконатись, що повторна обробка нічого не зламала. Також варто перевірити паралельну доставку дубля двом екземплярам одночасно — дедуплікація через SELECT, а потім INSERT, тут програє гонку, а унікальний ключ у базі ні. Це й відповідь на питання «що тестувати в event-driven системі»: дублікати, порядок, схема, повтори й replay.
How to test. The simplest test: publish the same event with the same event id twice and check that the side effect happened once. A harder and more honest one: stop the consumer after writing to the database but before committing the offset (through fault injection or a kill -9 at the right moment), bring it back and confirm that reprocessing broke nothing. It is also worth delivering a duplicate to two instances at the same time — deduplication via a SELECT followed by an INSERT loses that race, a unique key in the database does not. This is also the answer to "what to test in an event-driven system": duplicates, ordering, schema, retries and replay.
Підводні камені. Дедуплікація за хешем вмісту ламається, коли дві законні події однакові, наприклад два поповнення по 100 гривень; потрібен id події від продюсера. Таблиця оброблених id мусить оновлюватись у тій самій транзакції, що й бізнес-дані, інакше вікно для дубля залишається. І не варто вважати, що enable.idempotence у продюсері Kafka рятує споживача: воно прибирає лише дублі запису в партицію.
Pitfalls. Deduplicating by a content hash breaks when two legitimate events are identical, for example two top-ups of 100; you need an event id from the producer. The table of processed ids must be updated in the same transaction as the business data, otherwise a window for a duplicate remains. And don’t assume enable.idempotence on the Kafka producer protects the consumer: it only removes duplicate writes to a partition.
Уточнення. Скільки тримати id оброблених подій? Щонайменше довше, ніж максимальний можливий інтервал повторної доставки: retention топіка чи час життя повідомлення в DLQ з ручним replay. Інакше старий дубль пройде як нова подія.
Follow-up. How long should processed event ids be kept? At least longer than the maximum possible redelivery interval: the topic retention or the time a message may sit in a DLQ awaiting manual replay. Otherwise an old duplicate passes as a new event.
CREATE TABLE processed_events (
event_id UUID PRIMARY KEY, -- id від продюсера
handled_at TIMESTAMPTZ DEFAULT NOW()
);
BEGIN;
INSERT INTO processed_events (event_id)
VALUES ('7f3c9a8e-1b2d-4c5e-9f00-aa11bb22cc33')
ON CONFLICT (event_id) DO NOTHING
RETURNING event_id; -- порожньо = дубль, пропускаємо
UPDATE loyalty SET points = points + 50 WHERE user_id = 42;
COMMIT; -- offset комітимо лише після COMMIT
-- асерт тесту після подвійної публікації
SELECT points FROM loyalty WHERE user_id = 42; -- очікую +50
CREATE TABLE processed_events (
event_id UUID PRIMARY KEY, -- id from the producer
handled_at TIMESTAMPTZ DEFAULT NOW()
);
BEGIN;
INSERT INTO processed_events (event_id)
VALUES ('7f3c9a8e-1b2d-4c5e-9f00-aa11bb22cc33')
ON CONFLICT (event_id) DO NOTHING
RETURNING event_id; -- empty = duplicate, skip it
UPDATE loyalty SET points = points + 50 WHERE user_id = 42;
COMMIT; -- commit the offset only after COMMIT
-- test assertion after publishing twice
SELECT points FROM loyalty WHERE user_id = 42; -- expect +50
event_id робить дедуплікацію атомарною: дві паралельні вставки не можуть обидві пройти. ON CONFLICT DO NOTHING RETURNING у PostgreSQL повертає рядок лише для нової події, тож код споживача бачить порожній результат і виходить без побічного ефекту (на цьому місці він робить ROLLBACK). Ефект і позначка «оброблено» живуть в одній транзакції.event_id makes deduplication atomic: two concurrent inserts cannot both succeed. ON CONFLICT DO NOTHING RETURNING in PostgreSQL returns a row only for a new event, so the consumer sees an empty result and exits without a side effect (at that point it issues a ROLLBACK). The effect and the "handled" mark live in one transaction.Що це. Kafka гарантує порядок лише в межах однієї партиції. Події з однаковим ключем потрапляють в одну партицію, поки кількість партицій не змінюється, тому ключем обирають id сутності: order_id, account_id. Між різними ключами порядку немає взагалі. У RabbitMQ порядок у черзі зберігається для одного споживача, але кілька конкуруючих споживачів, requeue після nack чи відкладений ретрай легко переставляють повідомлення місцями.
What it is. Kafka guarantees order only within a single partition. Events with the same key land in the same partition as long as the partition count does not change, which is why the entity id is chosen as the key: order_id, account_id. Across different keys there is no ordering at all. In RabbitMQ a queue keeps order for a single consumer, but several competing consumers, a requeue after a nack or a delayed retry easily swap messages around.
Навіщо. Бізнес-логіка часто неявно покладається на послідовність: «created» до «paid», «paid» до «shipped». Якщо «order.cancelled» обробиться раніше за «order.paid», система може відправити товар за скасованим замовленням. Навіть з правильним ключем порядок ламають ретраї: повідомлення, відкладене на повтор в окремий топік, повернеться після наступних подій того ж замовлення.
Why. Business logic often relies on sequence implicitly: "created" before "paid", "paid" before "shipped". If "order.cancelled" is processed before "order.paid", the system may ship goods for a cancelled order. Even with the right key, retries break the order: a message parked in a separate retry topic comes back after later events for the same order.
Як тестувати. Спершу перевіряю, яким ключем продюсер публікує подію: у тесті на продюсера це такий самий асерт, як і тіло. Далі подаю споживачу події в зворотному порядку й дивлюсь, що він робить: чекає на попередню, відкидає застарілу за номером версії чи падає. Стійкий споживач зберігає версію сутності й застосовує подію, лише якщо її версія більша за поточну. Окремий кейс — дубль старої події після нової: стан не повинен відкотитись назад.
How to test. First I check which key the producer publishes with: in a producer test that is an assertion just like the body. Then I feed the consumer events in reverse order and see what it does: waits for the previous one, discards a stale one by version number, or crashes. A robust consumer stores the entity version and applies an event only if its version is higher than the current one. A separate case is a duplicate of an old event after a new one: the state must not roll back.
Підводні камені. Збільшення кількості партицій міняє відображення ключ — партиція для нових повідомлень, і на межі цієї зміни порядок для частини ключів ламається. Мітки часу з різних сервісів не годяться для впорядкування: годинники розходяться на десятки мілісекунд. А в продюсері з ретраями й max.in.flight більше одного порядок зберігається лише з увімкненою ідемпотентністю, яка в сучасних клієнтах Kafka ввімкнена за замовчуванням.
Pitfalls. Increasing the partition count changes the key-to-partition mapping for new messages, and around that change the order breaks for some keys. Timestamps from different services are no good for ordering: clocks drift by tens of milliseconds. And in a producer with retries and more than one in-flight request, order is preserved only with idempotence enabled, which modern Kafka clients turn on by default.
Уточнення. Чи потрібен глобальний порядок усіх подій? Майже ніколи: він означає одну партицію й відсутність паралелізму. Досить порядку в межах сутності, і саме цю межу тестувальник має з’ясувати й зафіксувати в тест-кейсах.
Follow-up. Is a global order of all events needed? Almost never: it means a single partition and no parallelism. Order within an entity is enough, and that boundary is exactly what a tester must clarify and pin down in test cases.
test('застаріла подія ігнорується за версією', async () => {
const id = 'ord-' + Date.now();
// навмисно зворотний порядок: v3, потім v2
await publish('orders', id, { id, status: 'SHIPPED', version: 3 });
await publish('orders', id, { id, status: 'PAID', version: 2 });
await eventually(async () => {
const order = await api.get('/orders/' + id);
expect(order.status).toBe('SHIPPED'); // не PAID
expect(order.version).toBe(3);
}, { timeoutMs: 10000 });
});
test('a stale event is ignored by version', async () => {
const id = 'ord-' + Date.now();
// reverse order on purpose: v3, then v2
await publish('orders', id, { id, status: 'SHIPPED', version: 3 });
await publish('orders', id, { id, status: 'PAID', version: 2 });
await eventually(async () => {
const order = await api.get('/orders/' + id);
expect(order.status).toBe('SHIPPED'); // not PAID
expect(order.version).toBe(3);
}, { timeoutMs: 10000 });
});
publish(topic, key, value) — тестовий хелпер продюсера; другий аргумент — ключ, тож обидві події йдуть в одну партицію і справді приходять у зворотному порядку. eventually повторює асерт до таймауту, бо обробка асинхронна. Версія в події — монотонний лічильник сутності, за яким споживач відкидає застарілі оновлення.publish(topic, key, value) is a test producer helper; the second argument is the key, so both events go to one partition and really arrive in reverse order. eventually retries the assertion until the timeout, because processing is asynchronous. The version in the event is a monotonic per-entity counter the consumer uses to discard stale updates.Що це. Poison message — повідомлення, яке споживач не зможе обробити ніколи: зламаний JSON, невідома версія схеми, посилання на неіснуючий запис. Без захисту воно блокує партицію в Kafka або нескінченно повертається в чергу RabbitMQ. Dead letter queue — окреме місце, куди після вичерпання спроб відкладають такі повідомлення разом з причиною, щоб потік ішов далі. У RabbitMQ це вбудований механізм dead-letter exchange; у Kafka окремого механізму в брокері немає, DLQ реалізують клієнтські бібліотеки, наприклад Spring Kafka, або Kafka Connect.
What it is. A poison message is one the consumer will never be able to process: broken JSON, an unknown schema version, a reference to a record that does not exist. Without protection it blocks a partition in Kafka or bounces back into a RabbitMQ queue forever. A dead letter queue is a separate place where such messages are parked with the reason after retries are exhausted, so the flow can move on. In RabbitMQ this is the built-in dead-letter exchange; Kafka has no such mechanism in the broker, so a DLQ is implemented by client libraries such as Spring Kafka, or by Kafka Connect.
Навіщо. Помилки бувають двох типів. Тимчасові — таймаут бази, 503 від сусіда — лікуються повтором з паузою. Постійні — невалідні дані — повтором не лікуються, і кожна спроба лише марнує ресурси й затримує інші повідомлення. Добра політика розрізняє ці типи: кілька повторів з backoff для тимчасових, одразу DLQ для постійних, і в обох випадках алерт, коли DLQ непорожня.
Why. Errors come in two kinds. Transient ones — a database timeout, a 503 from a neighbour — are cured by a retry after a pause. Permanent ones — invalid data — are not, and each attempt only wastes resources and delays other messages. A good policy tells them apart: a few retries with backoff for transient errors, straight to the DLQ for permanent ones, and in both cases an alert when the DLQ is not empty.
Як тестувати. Три базові кейси. Перший: публікую свідомо зламане повідомлення й перевіряю, що воно опинилось у DLQ із заголовками про причину і вихідну чергу, а наступне валідне повідомлення оброблено без затримки. Другий: імітую тимчасовий збій залежності на дві спроби й дивлюсь, що третя успішна, повідомлення не потрапило в DLQ, а кількість спроб і паузи відповідають конфігурації. Третій: replay з DLQ після виправлення — повідомлення обробляється один раз і не створює дубля.
How to test. Three basic cases. First: I publish a deliberately broken message and check that it ended up in the DLQ with headers about the reason and the source queue, and that the next valid message was processed without delay. Second: I simulate a transient dependency failure for two attempts and see that the third succeeds, the message did not go to the DLQ, and the number of attempts and pauses match the configuration. Third: replay from the DLQ after a fix — the message is processed once and creates no duplicate.
Підводні камені. У RabbitMQ nack з requeue=true повертає повідомлення в голову черги, і без ліміту доставок воно крутиться вічно, з’їдаючи CPU. Для quorum-черг є параметр delivery-limit, для класичних лічильник треба рахувати самому. DLQ, в яку ніхто не дивиться, — це просто повільна втрата даних, тому я перевіряю й алерт на її глибину. І повтор у тому ж потоці блокує партицію Kafka на весь час backoff.
Pitfalls. In RabbitMQ a nack with requeue=true puts the message back at the head of the queue, and without a delivery limit it spins forever, eating CPU. Quorum queues have a delivery-limit setting; for classic queues you have to count attempts yourself. A DLQ nobody looks at is just slow data loss, so I also test the alert on its depth. And a retry inside the same thread blocks the Kafka partition for the whole backoff.
Уточнення. Інтерв’юер може спитати, що робити з повідомленнями в DLQ. Відповідь: розібрати причину, виправити код чи дані й повернути їх у основну чергу інструментом replay; сам цей інструмент теж потребує тесту на ідемпотентність.
Follow-up. The interviewer may ask what to do with messages in the DLQ. Answer: find the cause, fix the code or the data, and send them back to the main queue with a replay tool; that tool itself also needs an idempotency test.
# відхилені (requeue=false) повідомлення з orders ідуть в orders.dlx
rabbitmqctl set_policy orders-dlx "^orders$" \
'{"dead-letter-exchange":"orders.dlx"}' --apply-to queues
# кейс 1: зламаний JSON потрапляє в DLQ, наступне проходить
publish orders '{"id": 1, "total": ' # обірваний JSON
publish orders '{"id": 2, "total": 100}'
rabbitmqctl list_queues name messages
# очікую: orders 0, orders.dead 1; заголовок x-death з reason=rejected
# rejected (requeue=false) messages from orders go to orders.dlx
rabbitmqctl set_policy orders-dlx "^orders$" \
'{"dead-letter-exchange":"orders.dlx"}' --apply-to queues
# case 1: broken JSON lands in the DLQ, the next one passes
publish orders '{"id": 1, "total": ' # truncated JSON
publish orders '{"id": 2, "total": 100}'
rabbitmqctl list_queues name messages
# expect: orders 0, orders.dead 1; x-death header with reason=rejected
set_policy вішає на чергу orders правило: повідомлення, які споживач відхилив без requeue, минули TTL або переповнили чергу, пересилаються в exchange orders.dlx, до якого прив’язана черга orders.dead. publish тут — тестовий хелпер. Брокер додає заголовок x-death з причиною і вихідною чергою — саме його я й перевіряю.set_policy attaches a rule to the orders queue: messages the consumer rejected without requeue, that exceeded their TTL or overflowed the queue are forwarded to the orders.dlx exchange, to which the orders.dead queue is bound. publish here is a test helper. The broker adds an x-death header with the reason and the source queue, and that is what I assert on.Що це. Saga замінює розподілену транзакцію послідовністю локальних: кожен сервіс комітить свою частину у власній базі й публікує подію, а якщо крок далі по ланцюжку падає, попередні кроки скасовуються компенсаційними діями — повернення коштів, зняття резерву, скасування доставки. В оркестрації одним процесом керує координатор, який знає всі кроки; у хореографії сервіси реагують на події один одного без центру. Ізоляції, як у ACID, немає: проміжні стани видно іншим, тому їх роблять явними, наприклад статус PENDING.
What it is. A saga replaces a distributed transaction with a sequence of local ones: each service commits its part in its own database and publishes an event, and if a later step fails, the earlier steps are undone by compensating actions — a refund, releasing a reservation, cancelling a delivery. In orchestration a coordinator that knows all the steps drives the process; in choreography services react to each other’s events with no centre. There is no isolation as in ACID: intermediate states are visible to others, so they are made explicit, for example a PENDING status.
Навіщо QA. Щасливий шлях саги зазвичай працює з першого дня. Гроші губляться в гілках збою: оплата пройшла, а резерв на складі не створився; компенсація повернення коштів сама впала на таймауті; повідомлення про скасування прийшло раніше, ніж про оплату. Кількість таких гілок росте з кожним кроком, і без систематичного підходу половина з них залишається неперевіреною.
Why QA cares. A saga’s happy path usually works from day one. Money gets lost in the failure branches: payment went through but the warehouse reservation was not created; the refund compensation itself failed on a timeout; the cancellation message arrived before the payment one. The number of such branches grows with every step, and without a systematic approach half of them stay untested.
Як тестувати. Я будую матрицю «крок, на якому стався збій, × очікуваний кінцевий стан кожного сервісу». Для кожного рядка ін’єктую збій (заглушка повертає помилку, Toxiproxy обриває з’єднання, споживач зупинений) і перевіряю, що сага прийшла в узгоджений кінцевий стан: або все завершено, або все компенсовано. Окремо тестую збій самої компенсації: вона має повторюватись і бути ідемпотентною, бо повернення коштів двічі — такий самий інцидент, як і нуль разів. Для оркестратора перевіряю ще відновлення після рестарту посеред саги: стан має зберігатись, а не жити в пам’яті.
How to test. I build a matrix of "the step where the failure happened × the expected final state of each service". For each row I inject the failure (a stub returns an error, Toxiproxy cuts the connection, the consumer is stopped) and check that the saga reached a consistent final state: either everything completed or everything was compensated. Separately I test a failure of the compensation itself: it must be retried and be idempotent, because refunding twice is as much of an incident as refunding zero times. For an orchestrator I also check recovery after a restart mid-saga: the state must be persisted, not kept in memory.
Підводні камені. Деякі дії не мають компенсації: надісланий лист не відкличеш, тому їх ставлять останніми кроками. Таймаут не означає відмову — платіжний провайдер міг списати кошти, і компенсація «на всякий випадок» має спершу з’ясувати фактичний стан. У хореографії легко отримати цикл подій, а загальну картину саги не видно ніде, крім трасування.
Pitfalls. Some actions cannot be compensated: you can’t unsend an email, so such steps go last. A timeout does not mean a refusal — the payment provider may have charged the card, so a "just in case" compensation must first find out the actual state. With choreography it is easy to create an event loop, and the overall picture of a saga is visible nowhere except in tracing.
Уточнення. Часто питають, як переконатись, що сага не зависла. Я додаю тест-кейс і моніторинг на «застряглі» саги: кількість екземплярів у проміжному статусі довше за SLA кроку має бути нуль, а для решти потрібен механізм дотискання.
Follow-up. People often ask how to make sure a saga did not get stuck. I add a test case and monitoring for "stuck" sagas: the number of instances in an intermediate status for longer than the step’s SLA must be zero, and the rest need a mechanism to push them through.
Крок Дія Компенсація
1 Order створити PENDING позначити CANCELLED
2 Payment списати кошти повернути кошти
3 Inventory зарезервувати зняти резерв
4 Shipping створити доставку скасувати доставку
5 Notification лист «підтверджено» (немає, тому останній)
Збій на | Order | Payment | Inventory | Shipping
---------+-----------+-----------+-----------+---------
крок 2 | CANCELLED | не списано| - | -
крок 3 | CANCELLED | ПОВЕРНЕНО | - | -
крок 4 | CANCELLED | ПОВЕРНЕНО | ЗНЯТО | -
комп. 2 | ретрай до успіху, повернення рівно 1 раз
рестарт | сага продовжується з останнього кроку
Step Action Compensation
1 Order create PENDING mark CANCELLED
2 Payment charge the card refund
3 Inventory reserve stock release reservation
4 Shipping create delivery cancel delivery
5 Notification "confirmed" email (none, so it goes last)
Fail at | Order | Payment | Inventory | Shipping
---------+-----------+-----------+-----------+---------
step 2 | CANCELLED | no charge | - | -
step 3 | CANCELLED | REFUNDED | - | -
step 4 | CANCELLED | REFUNDED | RELEASED | -
comp. 2 | retried until success, refund exactly once
restart | saga resumes from the last step
Що це. CQRS розділяє модель запису (команди, що перевіряють інваріанти й змінюють стан) і модель читання (денормалізовані проєкції під конкретні екрани). Часто вони живуть у різних сховищах і синхронізуються подіями, тож читання відстає від запису. Event Sourcing іде далі: джерелом правди стає не поточний рядок у таблиці, а незмінний потік подій — AccountOpened, MoneyDeposited, MoneyWithdrawn. Поточний стан отримують, програючи події, а для швидкості зберігають знімки (snapshots).
What it is. CQRS separates the write model (commands that check invariants and change state) from the read model (denormalised projections built for specific screens). They often live in different stores and are synchronised by events, so reads lag behind writes. Event Sourcing goes further: the source of truth is not the current row in a table but an immutable stream of events — AccountOpened, MoneyDeposited, MoneyWithdrawn. The current state is obtained by replaying events, and snapshots are stored for speed.
Навіщо QA. Ці патерни дають повну історію й масштабоване читання, але приносять власні класи багів. Проєкція може відставати, пропустити подію чи обробити її двічі. Перебудова проєкції з нуля може дати інший результат, ніж інкрементальне оновлення, якщо обробник залежить від поточного часу чи зовнішнього сервісу. А події, записані три роки тому в старому форматі, мусять читатись новим кодом.
Why QA cares. These patterns give a full history and scalable reads, but they bring their own bug classes. A projection may lag, miss an event or process it twice. Rebuilding a projection from scratch may give a different result from incremental updates if a handler depends on the current time or an external service. And events written three years ago in an old format must still be readable by new code.
Як тестувати. Агрегат тестую у стилі given-when-then: дано попередні події, виконується команда, очікую нові події або відмову. Це швидкі юніт-тести без бази. Для проєкцій — тест на детермінізм: програю весь потік у чисту проєкцію й порівнюю результат з тією, що оновлювалась інкрементально; вони мають збігтись до рядка. Перевіряю версіонування: подія старої версії проходить через upcaster і дає той самий стан. І оптимістичну конкуренцію: дві команди на одну версію потоку — одна з них має отримати конфлікт.
How to test. I test an aggregate in given-when-then style: given prior events, a command runs, and I expect new events or a rejection. These are fast unit tests with no database. For projections, a determinism test: I replay the whole stream into a clean projection and compare it with the incrementally updated one; they must match row for row. I check versioning: an old-version event goes through an upcaster and yields the same state. And optimistic concurrency: two commands against the same stream version — one of them must get a conflict.
Підводні камені. UI, що одразу після команди читає з проєкції, показує старі дані; це треба або явно тестувати як очікуване відставання, або повертати результат з команди. Подію не можна «виправити» в потоці — лише компенсувати новою подією, і тести, які чистять дані через DELETE, приховують цю реальність. Видалення персональних даних за GDPR у незмінному журналі — окрема складна тема, яку варто згадати.
Pitfalls. A UI that reads from a projection right after a command shows old data; this must either be tested explicitly as expected lag or the command must return the result. An event cannot be "fixed" in the stream — only compensated by a new event, and tests that clean data with DELETE hide that reality. Deleting personal data under GDPR from an immutable log is a hard topic of its own, worth mentioning.
Уточнення. Чи завжди CQRS іде разом з Event Sourcing? Ні: CQRS можна мати зі звичайною реляційною базою для запису й окремою проєкцією для пошуку, а Event Sourcing без розділення читання незручний, але можливий.
Follow-up. Does CQRS always come with Event Sourcing? No: you can have CQRS with an ordinary relational database for writes and a separate projection for search, while Event Sourcing without separate reads is awkward but possible.
Команда --> Агрегат (інваріанти) --> Потік подій acc-42
v1 AccountOpened
v2 MoneyDeposited 500
v3 MoneyWithdrawn 200
|
+--------------------+-------------+
v v
Проєкція «баланс» Проєкція «виписка»
(відстає на мс..с) (перебудовується з v1)
Command --> Aggregate (invariants) --> Event stream acc-42
v1 AccountOpened
v2 MoneyDeposited 500
v3 MoneyWithdrawn 200
|
+--------------------+-------------+
v v
"balance" projection "statement" projection
(lags by ms..s) (rebuilt from v1)
test('проєкція детермінована при replay', async () => {
const live = await readModel.dump('balances');
await readModel.createEmpty('balances_rebuild');
await replayAll({ from: 0, into: 'balances_rebuild' });
const rebuilt = await readModel.dump('balances_rebuild');
expect(rebuilt).toEqual(live); // рядок у рядок
});
test('the projection is deterministic on replay', async () => {
const live = await readModel.dump('balances');
await readModel.createEmpty('balances_rebuild');
await replayAll({ from: 0, into: 'balances_rebuild' });
const rebuilt = await readModel.dump('balances_rebuild');
expect(rebuilt).toEqual(live); // row for row
});
readModel і replayAll: знімає поточну проєкцію, будує нову з першої події в чисту таблицю й порівнює. Розбіжність означає недетермінований обробник або пропущену подію.readModel and replayAll: it dumps the current projection, builds a new one from the first event into a clean table, and compares. A difference means a non-deterministic handler or a missed event.Що це. Schema registry — сервіс, який зберігає версії схем повідомлень (Avro, Protobuf, JSON Schema) для кожного subject, зазвичай «топік-value». Продюсер реєструє схему й кладе в повідомлення лише її id, споживач за цим id дістає схему для десеріалізації. Головне для тестування: реєстр перевіряє сумісність нової версії з попередніми згідно з налаштованим режимом і відхиляє несумісну.
What it is. A schema registry is a service that stores versions of message schemas (Avro, Protobuf, JSON Schema) per subject, usually "topic-value". The producer registers a schema and puts only its id into the message, and the consumer fetches the schema by that id to deserialise. The key point for testing: the registry checks a new version’s compatibility with earlier ones according to the configured mode and rejects an incompatible one.
Навіщо. У подієвій системі продюсер не знає всіх своїх споживачів, а в топіку одночасно лежать повідомлення старої й нової версії. Режими визначають, хто може оновлюватись першим. BACKWARD (у Confluent Schema Registry за замовчуванням) — новий споживач читає старі дані, тож оновлюють спершу споживачів; в Avro так можна видаляти поля й додавати нові зі значенням за замовчуванням. FORWARD — старий споживач читає нові дані, оновлюють спершу продюсера. FULL — обидва напрямки. Варіанти з _TRANSITIVE перевіряють проти всіх попередніх версій, а не лише останньої.
Why. In an event-driven system a producer doesn’t know all of its consumers, and a topic holds messages of both the old and the new version at the same time. The modes decide who may upgrade first. BACKWARD (the default in Confluent Schema Registry) — a new consumer reads old data, so consumers are upgraded first; in Avro this allows deleting fields and adding new ones with a default. FORWARD — an old consumer reads new data, so the producer is upgraded first. FULL — both directions. The _TRANSITIVE variants check against all previous versions, not just the latest.
Як тестувати. Найдешевший контроль — крок у CI продюсера, який перед мерджем надсилає нову схему в ендпоінт перевірки сумісності реєстру й падає, якщо відповідь is_compatible false. Реєстрація при цьому не відбувається. Далі — тест серіалізації: повідомлення, записане старою схемою, десеріалізується новим кодом споживача, і навпаки для FORWARD. Для критичних топіків я тримаю набір «золотих» повідомлень усіх минулих версій і проганяю їх через поточний споживач.
How to test. The cheapest control is a step in the producer’s CI that, before merge, sends the new schema to the registry’s compatibility endpoint and fails if the answer is is_compatible false. Nothing is registered by that call. Next comes a serialisation test: a message written with the old schema is deserialised by the new consumer code, and the reverse for FORWARD. For critical topics I keep a set of "golden" messages of every past version and run them through the current consumer.
Підводні камені. Сумісність схеми не означає сумісності семантики: поле amount, яке раптом стало в копійках замість гривень, пройде будь-яку перевірку. Автореєстрація схем продюсером у продакшені обходить рев’ю, тому її часто вимикають. Режим NONE, виставлений «тимчасово» для термінового релізу, так і лишається назавжди, і тест, що звіряє конфіг subject, теж має сенс.
Pitfalls. Schema compatibility does not mean semantic compatibility: an amount field that suddenly switches from units to cents passes every check. Auto-registration of schemas by the producer in production bypasses review, so it is often disabled. A NONE mode set "temporarily" for an urgent release tends to stay forever, so a test that checks the subject’s config also makes sense.
Уточнення. Як це пов’язано з Pact? Schema registry перевіряє структуру для всіх можливих споживачів одразу, а контрактні тести — конкретні очікування конкретного споживача, включно зі значеннями. Для важливих подій корисно мати обидва рівні.
Follow-up. How does it relate to Pact? A schema registry checks structure for all possible consumers at once, while contract tests check a specific consumer’s concrete expectations, including values. For important events both levels are useful.
# схема йде рядком усередині JSON, тому загортаємо її через jq
jq -n --rawfile s order-created-v2.avsc '{schema: $s}' > body.json
curl -s -X POST \
-H "Content-Type: application/vnd.schemaregistry.v1+json" \
--data @body.json \
"$SR_URL/compatibility/subjects/orders-value/versions/latest?verbose=true"
# очікую {"is_compatible": true}; false = червоний білд
# який режим налаштований для subject
curl -s "$SR_URL/config/orders-value"
# the schema travels as a string inside JSON, so wrap it with jq
jq -n --rawfile s order-created-v2.avsc '{schema: $s}' > body.json
curl -s -X POST \
-H "Content-Type: application/vnd.schemaregistry.v1+json" \
--data @body.json \
"$SR_URL/compatibility/subjects/orders-value/versions/latest?verbose=true"
# expect {"is_compatible": true}; false = red build
# which mode is configured for the subject
curl -s "$SR_URL/config/orders-value"
jq --rawfile читає файл схеми як рядок і вкладає його в поле schema, бо API реєстру чекає саме рядок, а не вкладений об’єкт. POST /compatibility/subjects/{subject}/versions/latest лише перевіряє нову схему проти останньої версії, нічого не реєструючи; verbose=true додає пояснення, чому схема несумісна. GET /config/{subject} повертає режим сумісності (якщо його не задано на рівні subject, діє глобальний).jq --rawfile reads the schema file as a string and puts it into the schema field, because the registry API expects a string, not a nested object. POST /compatibility/subjects/{subject}/versions/latest only checks the new schema against the latest version without registering anything; verbose=true adds an explanation of why a schema is incompatible. GET /config/{subject} returns the compatibility mode (if none is set at subject level, the global one applies).Контекст і ризики. Сервіс сповіщень отримує бізнес-події (замовлення оплачено, пароль змінено), вирішує, кому, яким каналом і яким шаблоном писати, і передає повідомлення зовнішнім провайдерам через черги. Головні ризики я формулюю з погляду користувача: дубль (два SMS з кодом), пропуск (лист про скидання пароля не прийшов), не той канал чи час (push о третій ночі попри тихі години), не та мова чи зламаний шаблон, а для бізнесу — рахунок від SMS-провайдера за шторм повторів.
Context and risks. A notification service receives business events (order paid, password changed), decides whom to notify, through which channel and with which template, and hands messages to external providers through queues. I phrase the main risks from the user’s side: a duplicate (two SMS with a code), a miss (the password reset email never came), the wrong channel or time (a push at 3 a.m. despite quiet hours), the wrong language or a broken template, and for the business — an SMS provider bill for a retry storm.
Стратегія по рівнях. Юніт-тести покривають правила маршрутизації: налаштування користувача, тихі години з часовими поясами, пріоритети (код безпеки ігнорує тихі години, маркетинг — ні). Тести шаблонів рендерять кожен шаблон кожною мовою з граничними даними: довге ім’я, відсутнє поле, емодзі в SMS, що змінює кодування й ліміт символів. Компонентні тести ганяють сервіс з реальним брокером у Testcontainers і WireMock замість провайдерів. Контрактні тести фіксують формат подій від сервісів-продюсерів. E2E лишаю небагато: один лист у тестову скриньку на кшталт Mailpit на кожен критичний потік.
Strategy by layer. Unit tests cover the routing rules: user preferences, quiet hours with time zones, priorities (a security code ignores quiet hours, marketing does not). Template tests render every template in every language with edge data: a long name, a missing field, an emoji in an SMS that changes the encoding and the character limit. Component tests run the service with a real broker in Testcontainers and WireMock instead of the providers. Contract tests pin the event format from producer services. I keep few E2E tests: one email to a test inbox such as Mailpit per critical flow.
Ключові тест-кейси. Та сама подія двічі — одне повідомлення, дедуплікація за id події та каналом. Провайдер повертає 429 — повтор після Retry-After, без втрати. Провайдер лежить — перемикання на резервного або відкладена доставка, circuit breaker не дає накопичити тисячі таймаутів. Невідомий результат (таймаут після відправки) — не надсилаємо код удруге наосліп. Вебхуки статусів доставки приходять не в порядку: «delivered» раніше за «sent» не відкочує статус. Відписка діє одразу. Пік на мільйон повідомлень — черга розбирається в межах SLO, а транзакційні не стоять за маркетинговими.
Key test cases. The same event twice — one message, deduplicated by event id and channel. The provider returns 429 — retry after Retry-After, nothing lost. The provider is down — failover to a backup or deferred delivery, with a circuit breaker so thousands of timeouts don’t pile up. Unknown outcome (a timeout after sending) — don’t blindly send the code again. Delivery status webhooks arrive out of order: "delivered" before "sent" does not roll the status back. An unsubscribe takes effect at once. A burst of a million messages — the queue drains within the SLO, and transactional messages don’t wait behind marketing ones.
Підводні камені. Тестове середовище, яке випадково шле справжні SMS реальним номерам, — класичний інцидент; провайдери мають пісочниці, а номери й адреси варто обмежувати allow-list. Часові пояси й перехід на літній час ламають тихі години двічі на рік. Окремі черги для транзакційних і масових розсилок треба перевірити під навантаженням, інакше код входу чекатиме за розсилкою знижок.
Pitfalls. A test environment that accidentally sends real SMS to real numbers is a classic incident; providers have sandboxes, and numbers and addresses should be limited by an allow-list. Time zones and daylight saving changes break quiet hours twice a year. Separate queues for transactional and bulk sends must be checked under load, otherwise a login code waits behind a discount campaign.
Уточнення. Що моніторити в продакшені? Частку доставлених за каналами й провайдерами, час від події до доставки для транзакційних повідомлень, глибину черг і DLQ, кількість відписок після розсилки — різкий стрибок зазвичай означає баг у таргетингу.
Follow-up. What to monitor in production? The delivered share per channel and provider, the time from event to delivery for transactional messages, queue and DLQ depth, and unsubscribes after a campaign — a sharp spike usually means a targeting bug.
Order/Auth --events--> [notify.in] --> Router (налаштування,
тихі години, дедуп)
|
+--------------+-----------+---------+
v v v
[q.transact] [q.bulk] [q.push]
| | |
Email/SMS провайдер A (+резерв B) APNs / FCM
|
webhook статусу --> Status service
Рівень Що Інструмент
unit правила, тихі години, TZ Jest/JUnit
шаблони мови, довгі поля, SMS-ліміт snapshot + лінтер
компонент дубль, 429, збій, DLQ Testcontainers+WireMock
контракт формат подій продюсерів Pact / schema registry
E2E 1 лист на критичний потік Mailpit
навантаж. 1 млн, transact не чекає k6 + метрики черг
Order/Auth --events--> [notify.in] --> Router (preferences,
quiet hours, dedup)
|
+--------------+-----------+---------+
v v v
[q.transact] [q.bulk] [q.push]
| | |
Email/SMS provider A (+backup B) APNs / FCM
|
status webhook --> Status service
Layer What Tool
unit rules, quiet hours, TZ Jest/JUnit
templates locales, long fields, SMS snapshot + linter
component duplicate, 429, outage, DLQ Testcontainers+WireMock
contract producer event format Pact / schema registry
E2E 1 email per critical flow Mailpit
load 1M burst, transact not held k6 + queue metrics
Що це. Таймаут — межа, скільки клієнт готовий чекати на залежний сервіс, перш ніж здатися й повернути помилку або запасну відповідь. Розрізняють connect timeout (скільки чекати на встановлення TCP-з'єднання, зазвичай сотні мілісекунд) і read, або response, timeout (скільки чекати на відповідь після відправлення запиту). Над ними стоїть дедлайн усієї операції: у gRPC він передається вниз по ланцюжку, щоб нижній сервіс не працював над запитом, на який верхній уже не чекає.
What it is. A timeout is the limit on how long a client is willing to wait for a dependency before giving up and returning an error or a fallback response. There is a connect timeout (how long to wait for the TCP connection, usually hundreds of milliseconds) and a read, or response, timeout (how long to wait for the answer once the request is sent). Above them sits the deadline of the whole operation: gRPC propagates it down the call chain so a lower service does not keep working on a request the caller has already abandoned.
Навіщо. Повільна залежність небезпечніша за мертву. Мертва швидко відповідає помилкою, а повільна тримає потоки й з'єднання з пулу; за кілька хвилин вони закінчуються, і падає вже сервіс, який сам по собі здоровий. Так починається каскадний збій. Друга причина — бюджет часу: якщо gateway чекає 10 секунд, сервіс A не може чекати на B 15 секунд, бо його відповідь уже нікому не потрібна.
Why. A slow dependency is more dangerous than a dead one. A dead one fails fast, while a slow one holds threads and pooled connections; within minutes they run out and a perfectly healthy service goes down too. That is how a cascading failure starts. The second reason is the time budget: if the gateway waits 10 seconds, service A cannot wait 15 seconds for B, because nobody will be listening for its answer.
Як тестувати. Підміняю залежність стабом із затримкою (WireMock, Toxiproxy) і перевіряю три речі. Сервіс відповідає контрольованою помилкою чи фолбеком приблизно за свій таймаут, а не висить до кінця затримки. Потоки й з'єднання звільняються: метрики пулу повертаються до базового рівня. У логах і метриках видно саме таймаут, а не безлике «500». Окремо звіряю ланцюжок: таймаут кожної ланки менший за таймаут того, хто її викликає.
How to test. I replace the dependency with a delayed stub (WireMock, Toxiproxy) and check three things. The service answers with a controlled error or fallback in roughly its own timeout instead of hanging for the whole delay. Threads and connections are released: pool metrics return to baseline. Logs and metrics show a timeout specifically, not a faceless "500". Separately I compare the chain: every hop's timeout is shorter than its caller's.
Підводні камені. Значення за замовчуванням у бібліотеках бувають нескінченними: наприклад, запит у java.net.http.HttpClient без явного timeout() чекає відповідь без обмеження. Таймаут множиться на ретраї: три спроби по 3 секунди — це вже 9 секунд, більше за бюджет верхнього рівня. І таймаут на клієнті не скасовує роботу на сервері: списання могло пройти, хоча клієнт побачив помилку.
Pitfalls. Library defaults can be infinite: a java.net.http.HttpClient request without an explicit timeout() waits for a response with no limit. A timeout is multiplied by retries: three attempts of 3 seconds are 9 seconds, more than the upstream budget. And a client-side timeout does not cancel the work on the server: the charge may have gone through even though the client saw an error.
Уточнення. «Як обрати значення?» — від виміряного p99 залежності з невеликим запасом, а не навмання, і так, щоб сума таймаутів і ретраїв умістилася в SLO верхнього рівня.
Follow-up. "How do you pick the value?" From the dependency's measured p99 plus a small margin, not by guessing, and so that timeouts plus retries fit inside the upstream SLO.
{
"request": { "method": "GET", "urlPath": "/api/rates" },
"response": {
"status": 200,
"fixedDelayMilliseconds": 5000,
"jsonBody": { "currency": "EUR", "rate": 1.08 }
}
}
відповідь приходить за ~2 с, а не за 5 с
код 503 або кешований курс з позначкою stale=true
http_client_timeouts_total{target="rates"} +1
активні з'єднання пулу повертаються до базового рівня
у лозі: "rates timeout after 2000 ms", а не stack trace 500
the response arrives in ~2 s, not 5 s
status 503 or a cached rate marked stale=true
http_client_timeouts_total{target="rates"} +1
active pool connections return to baseline
in the log: "rates timeout after 2000 ms", not a 500 stack trace
POST /__admin/mappings або файлом у каталозі mappings. fixedDelayMilliseconds змушує WireMock тримати відповідь 5 секунд, тобто довше за таймаут клієнта, тому тест бачить саме реакцію на таймаут. Другий блок — чекліст спостережень: час відповіді, код, метрика, стан пулу й повідомлення в лозі.POST /__admin/mappings or as a file in the mappings folder. fixedDelayMilliseconds makes WireMock hold the response for 5 seconds, longer than the client timeout, so the test observes the reaction to a timeout. The second block is a checklist of observations: response time, status, metric, pool state and log message.Що це. У HTTP ідемпотентність — властивість методу: n однакових запитів залишають сервер у тому самому стані, що й один. За RFC 9110 ідемпотентні GET, HEAD, OPTIONS, TRACE, PUT і DELETE, а POST і PATCH — ні. Йдеться про стан, а не про відповідь: перший DELETE /items/7 дає 204, другий може дати 404, але товару нема в обох випадках. PUT записує повне представлення ресурсу, тому повтор нічого не змінює.
What it is. In HTTP idempotency is a property of a method: n identical requests leave the server in the same state as one. Per RFC 9110, GET, HEAD, OPTIONS, TRACE, PUT and DELETE are idempotent, while POST and PATCH are not. It is about state, not about the response: the first DELETE /items/7 returns 204, the second may return 404, but the item is gone either way. PUT writes the full representation of a resource, so repeating it changes nothing.
Idempotency-Key — окремий механізм. Він робить безпечним повтор неідемпотентної операції, насамперед POST на створення платежу чи замовлення. Клієнт генерує унікальний ключ (UUID) на одну бізнес-операцію й передає його в заголовку. Сервер зберігає ключ разом із результатом і на повтор із тим самим ключем віддає збережену відповідь, не виконуючи дію вдруге. Так працюють Stripe та інші платіжні API, а заголовок описаний у чернетці IETF httpapi. Навіщо: мережа може обірвати відповідь уже після списання, клієнт не знає результату й повторює — без ключа це подвійне списання.
Idempotency-Key is a separate mechanism. It makes retrying a non-idempotent operation safe, above all a POST that creates a payment or an order. The client generates a unique key (a UUID) per business operation and sends it in a header. The server stores the key with the result and, on a repeat with the same key, returns the stored response without performing the action again. Stripe and other payment APIs work this way, and the header is described in an IETF httpapi draft. Why: the network can drop the response after the charge has happened, the client does not know the outcome and retries — without a key that is a double charge.
Як тестувати. Для методів: стан після одного й після двох PUT чи DELETE однаковий. Для ключа: той самий POST двічі з одним ключем дає однаковий статус і тіло, у базі один запис, до провайдера пішов один виклик; той самий ключ з іншим тілом повертає помилку (чернетка радить 422); два паралельні запити з одним ключем — виконується один, другий отримує 409; різні ключі створюють два записи; ключ після терміну зберігання (у Stripe — щонайменше 24 години) поводиться як новий.
How to test. For methods: the state after one and after two PUT or DELETE calls is the same. For the key: the same POST twice with one key gives the same status and body, one row in the database and one call to the provider; the same key with a different body returns an error (the draft suggests 422); two concurrent requests with one key — one runs, the other gets 409; different keys create two records; a key past its retention period (at least 24 hours at Stripe) behaves like a new one.
Підводні камені. Клієнт генерує новий ключ на кожну спробу замість кожної операції, і захист зникає. Сервер записує ключ лише після успіху, тож повтор після таймауту знову виконує дію. Ключ не прив'язаний до користувача, і чужий ключ повертає чужу відповідь.
Pitfalls. The client generates a new key per attempt instead of per operation, and the protection is gone. The server saves the key only after success, so a retry after a timeout performs the action again. The key is not scoped to the user, and someone else's key returns someone else's response.
Уточнення. «PATCH ідемпотентний?» — не гарантовано: PATCH з інкрементом лічильника точно ні, тож клієнт не може покладатися на повтор без ключа.
Follow-up. "Is PATCH idempotent?" Not guaranteed: a PATCH that increments a counter clearly is not, so a client cannot rely on retrying it without a key.
KEY=$(uuidgen)
BODY='{"orderId":"A-17","amount":4999,"currency":"UAH"}'
# 1) перша спроба: 201 і новий payment id
curl -s -X POST https://api.example.com/v1/payments \
-H "Idempotency-Key: $KEY" \
-H "Content-Type: application/json" -d "$BODY"
# 2) повтор з тим самим ключем: той самий id, без нового списання
curl -s -X POST https://api.example.com/v1/payments \
-H "Idempotency-Key: $KEY" \
-H "Content-Type: application/json" -d "$BODY"
# 3) той самий ключ, інша сума: очікую 422
curl -s -o /dev/null -w "%{http_code}\n" \
-X POST https://api.example.com/v1/payments \
-H "Idempotency-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{"orderId":"A-17","amount":5999,"currency":"UAH"}'
# 4) у базі рівно один платіж
psql -c "SELECT count(*) FROM payments WHERE order_id = 'A-17';"
KEY=$(uuidgen)
BODY='{"orderId":"A-17","amount":4999,"currency":"UAH"}'
# 1) first attempt: 201 and a new payment id
curl -s -X POST https://api.example.com/v1/payments \
-H "Idempotency-Key: $KEY" \
-H "Content-Type: application/json" -d "$BODY"
# 2) retry with the same key: same id, no new charge
curl -s -X POST https://api.example.com/v1/payments \
-H "Idempotency-Key: $KEY" \
-H "Content-Type: application/json" -d "$BODY"
# 3) same key, different amount: expect 422
curl -s -o /dev/null -w "%{http_code}\n" \
-X POST https://api.example.com/v1/payments \
-H "Idempotency-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{"orderId":"A-17","amount":5999,"currency":"UAH"}'
# 4) exactly one payment in the database
psql -c "SELECT count(*) FROM payments WHERE order_id = 'A-17';"
uuidgen дає один ключ на всю операцію, і він повторно використовується в кроках 1–3. -w "%{http_code}" друкує лише статус, щоб його було легко порівняти. Кроки 1 і 2 мають повернути однакове тіло з тим самим id платежу, крок 3 перевіряє, що ключ не можна перевикористати для іншого запиту, а SQL підтверджує результат у джерелі правди, а не лише у відповіді.uuidgen produces one key for the whole operation, reused in steps 1–3. -w "%{http_code}" prints only the status so it is easy to compare. Steps 1 and 2 must return the same body with the same payment id, step 3 checks the key cannot be reused for a different request, and the SQL confirms the outcome in the source of truth, not just in the response.Що це. Ретрай — повторна спроба виклику після тимчасової помилки. Exponential backoff означає, що пауза між спробами росте геометрично: 200 мс, 400, 800 і так далі до стелі. Jitter додає випадковість, щоб тисячі клієнтів, які впали одночасно, не повернулися теж одночасно. Популярний варіант full jitter: пауза — випадкове число від нуля до поточної межі.
What it is. A retry is another attempt at a call after a transient error. Exponential backoff means the pause between attempts grows geometrically: 200 ms, 400, 800 and so on up to a cap. Jitter adds randomness so that thousands of clients that failed at the same moment do not come back at the same moment too. A popular variant is full jitter: the pause is a random number between zero and the current limit.
Навіщо. Частина збоїв справді минуща: обірване з'єднання, перезапуск поду, 503 під час деплою, 429 від ліміту. Один повтор через кілька сотень мілісекунд рятує запит. Але повторювати можна лише безпечне: ідемпотентні операції або POST з ключем ідемпотентності. Помилки клієнта (400, 401, 403, 404, 422) не повторюють — друга спроба дасть те саме. На 429 і 503 з заголовком Retry-After клієнт має чекати стільки, скільки просить сервер.
Why. Some failures really are transient: a dropped connection, a pod restart, a 503 during a deploy, a 429 from a rate limit. One repeat a few hundred milliseconds later saves the request. But only safe things may be repeated: idempotent operations or a POST with an idempotency key. Client errors (400, 401, 403, 404, 422) are not retried — the second attempt gives the same result. On a 429 or a 503 with a Retry-After header the client must wait as long as the server asks.
Як тестувати. Стаб зі сценарієм «503, 503, 200» — операція успішна, до стаба рівно три запити, паузи ростуть. Стаб, який завжди дає 503, — клієнт здається після максимуму спроб і вкладається в загальний дедлайн. Відповідь 400 — рівно один запит. 429 з Retry-After: 2 — наступна спроба не раніше ніж за 2 секунди. У логах кожна спроба має номер і той самий request id, а метрика ретраїв росте.
How to test. A stub scripted as "503, 503, 200" — the operation succeeds, the stub receives exactly three requests, the pauses grow. A stub that always returns 503 — the client gives up after the maximum attempts and stays within the overall deadline. A 400 response — exactly one request. A 429 with Retry-After: 2 — the next attempt comes no sooner than 2 seconds later. In the logs every attempt has a number and the same request id, and the retry metric goes up.
Підводні камені. Retry storm: якщо три рівні сервісів роблять по три спроби, на нижній рівень прилітає до 27 викликів замість одного саме тоді, коли він і так перевантажений. Тому ретраї роблять на одному рівні або обмежують retry budget, наприклад не більше 10% додаткового трафіку. Без jitter повтори синхронізуються й б'ють хвилями.
Pitfalls. A retry storm: if three layers of services each make three attempts, the bottom layer receives up to 27 calls instead of one exactly when it is already overloaded. That is why retries live on one layer or are limited by a retry budget, for example no more than 10% extra traffic. Without jitter the retries synchronise and hit in waves.
Уточнення. «Хто має ретраїти — клієнт, сервіс чи mesh?» — хтось один; якщо Istio вже повторює запит, повтор у коді треба вимкнути або узгодити.
Follow-up. "Who should retry — the client, the service or the mesh?" Exactly one of them; if Istio already retries the request, the retry in code must be disabled or coordinated.
{
"mappings": [
{
"scenarioName": "flaky-charge",
"requiredScenarioState": "Started",
"newScenarioState": "second",
"request": { "method": "POST", "url": "/charge" },
"response": { "status": 503 }
},
{
"scenarioName": "flaky-charge",
"requiredScenarioState": "second",
"newScenarioState": "third",
"request": { "method": "POST", "url": "/charge" },
"response": { "status": 503 }
},
{
"scenarioName": "flaky-charge",
"requiredScenarioState": "third",
"request": { "method": "POST", "url": "/charge" },
"response": { "status": 200, "jsonBody": { "id": "ch_1" } }
}
]
}
base = 200 мс, cap = 5000 мс, full jitter
пауза(n) = random(0, min(cap, base * 2^n))
n=0: 0..200 n=1: 0..400 n=2: 0..800
n=3: 0..1600 n=4: 0..3200 n=5: 0..5000
POST /__admin/requests/count
{"method": "POST", "url": "/charge"} -> {"count": 3}
base = 200 ms, cap = 5000 ms, full jitter
pause(n) = random(0, min(cap, base * 2^n))
n=0: 0..200 n=1: 0..400 n=2: 0..800
n=3: 0..1600 n=4: 0..3200 n=5: 0..5000
POST /__admin/requests/count
{"method": "POST", "url": "/charge"} -> {"count": 3}
Started; кожна відповідь переводить його в newScenarioState, тож три однакові запити отримують 503, 503 і 200. Формула показує full jitter: межа подвоюється до стелі, а сама пауза випадкова в цих межах. Запит до /__admin/requests/count повертає, скільки викликів насправді дійшло до стаба, — це й є головне твердження тесту.Started state; each response moves it to newScenarioState, so three identical requests get 503, 503 and 200. The formula shows full jitter: the limit doubles up to the cap and the pause itself is random within it. The call to /__admin/requests/count returns how many calls actually reached the stub — that is the main assertion of the test.Що це. Circuit breaker — обгортка навколо виклику залежності з трьома станами. CLOSED: виклики йдуть, breaker рахує помилки у ковзному вікні. Коли частка помилок або повільних викликів перевищує поріг, він переходить в OPEN і відразу відхиляє виклики, не чіпаючи залежність; клієнт отримує фолбек або швидку помилку. Після паузи настає HALF_OPEN: пропускається кілька пробних викликів, і за їх результатом breaker повертається в CLOSED або знову в OPEN.
What it is. A circuit breaker wraps calls to a dependency and has three states. CLOSED: calls go through and the breaker counts failures in a sliding window. When the share of failed or slow calls exceeds a threshold, it moves to OPEN and rejects calls immediately without touching the dependency; the caller gets a fallback or a fast error. After a wait it becomes HALF_OPEN: a few trial calls are let through, and depending on their outcome the breaker returns to CLOSED or back to OPEN.
Навіщо. Якщо залежність лежить, немає сенсу тисячу разів на секунду чекати на неї таймаут. Breaker економить потоки викликача, дає залежності час піднятись без додаткового навантаження і робить деградацію передбачуваною: замість 30 секунд очікування користувач за мілісекунди бачить «рекомендації тимчасово недоступні».
Why. If a dependency is down there is no point waiting for its timeout a thousand times a second. The breaker saves the caller's threads, gives the dependency time to recover without extra load, and makes degradation predictable: instead of waiting 30 seconds the user sees "recommendations are temporarily unavailable" within milliseconds.
Як тестувати. Проходжу всі переходи зі стабом залежності. Спочатку здоровий стаб — стан CLOSED. Далі стаб повертає 500: після minimumNumberOfCalls викликів із часткою помилок вище порогу стан стає OPEN, наступні виклики миттєво отримують фолбек, а лічильник запитів на стабі не росте. Чекаю waitDurationInOpenState — стан HALF_OPEN, пропускається рівно дозволена кількість пробних викликів. Якщо стаб здоровий, стан CLOSED; якщо ні, знову OPEN. Стан беру з /actuator/circuitbreakers або з метрики resilience4j_circuitbreaker_state.
How to test. I walk through every transition with a stubbed dependency. First a healthy stub — state CLOSED. Then the stub returns 500: after minimumNumberOfCalls calls with a failure rate above the threshold the state becomes OPEN, subsequent calls get the fallback instantly, and the stub's request count stops growing. I wait waitDurationInOpenState — state HALF_OPEN, and exactly the permitted number of trial calls go through. If the stub is healthy the state is CLOSED; if not, OPEN again. I read the state from /actuator/circuitbreakers or the resilience4j_circuitbreaker_state metric.
Підводні камені. Бізнесові 4xx рахуються як збої, і breaker відкривається через валідацію, а не через аварію; це налаштовують через ignoreExceptions чи recordExceptions. У кожного пода власний breaker, тож на десяти подах картина відкривається нерівномірно. Фолбек ховає аварію, якщо на стан OPEN немає алерту.
Pitfalls. Business 4xx errors count as failures, and the breaker opens because of validation rather than an outage; this is tuned with ignoreExceptions or recordExceptions. Every pod has its own breaker, so across ten pods the circuit opens unevenly. A fallback hides the outage if there is no alert on the OPEN state.
Уточнення. «Чим breaker відрізняється від ретраю?» — ретрай допомагає пережити короткий збій, breaker зупиняє марні спроби під час довгого; їх ставлять разом, ретрай усередині breaker.
Follow-up. "How is a breaker different from a retry?" A retry helps survive a short glitch, a breaker stops pointless attempts during a long one; they are combined, with the retry inside the breaker.
resilience4j:
circuitbreaker:
instances:
recommendations:
slidingWindowType: COUNT_BASED
slidingWindowSize: 10
minimumNumberOfCalls: 5
failureRateThreshold: 50 # % помилок для OPEN
slowCallDurationThreshold: 2s
slowCallRateThreshold: 50 # % повільних для OPEN
waitDurationInOpenState: 10s
permittedNumberOfCallsInHalfOpenState: 3
automaticTransitionFromOpenToHalfOpenEnabled: true
resilience4j:
circuitbreaker:
instances:
recommendations:
slidingWindowType: COUNT_BASED
slidingWindowSize: 10
minimumNumberOfCalls: 5
failureRateThreshold: 50 # % of failures to OPEN
slowCallDurationThreshold: 2s
slowCallRateThreshold: 50 # % of slow calls to OPEN
waitDurationInOpenState: 10s
permittedNumberOfCallsInHalfOpenState: 3
automaticTransitionFromOpenToHalfOpenEnabled: true
1. стаб 200 -> 5 викликів -> CLOSED
2. стаб 500 -> 5 викликів -> OPEN
3. ще 20 викликів -> фолбек за < 10 мс, стаб отримав 0
4. чекаємо 10 с -> HALF_OPEN
5. стаб 200, 3 виклики -> CLOSED
6. (варіант) стаб 500 -> знову OPEN
1. stub 200 -> 5 calls -> CLOSED
2. stub 500 -> 5 calls -> OPEN
3. 20 more calls -> fallback in < 10 ms, stub got 0
4. wait 10 s -> HALF_OPEN
5. stub 200, 3 calls -> CLOSED
6. (variant) stub 500 -> OPEN again
automaticTransitionFromOpenToHalfOpenEnabled переводить його в HALF_OPEN без очікування наступного виклику, а три пробні виклики вирішують, закритись чи відкритись знову. Кроки тесту — це таблиця переходів, де кожен рядок має перевірюваний результат.automaticTransitionFromOpenToHalfOpenEnabled moves it to HALF_OPEN without waiting for the next call, and three trial calls decide whether it closes or opens again. The test steps are a transition table where every row has a checkable result.Що це. Bulkhead — перегородка, як у трюмі корабля: ресурси для різних залежностей розділені, щоб пробоїна в одному відсіку не затопила весь корабель. На практиці це окремі пули потоків або ліміти одночасних викликів на кожну залежність, окремі пули з'єднань до бази, окремі черги чи консюмери для різних клієнтів, ліміти CPU й пам'яті в Kubernetes. Graceful degradation — наступний крок: коли некритична частина недоступна, система продовжує виконувати головне без неї.
What it is. A bulkhead is a partition, like in a ship's hull: resources for different dependencies are separated so a breach in one compartment does not sink the whole ship. In practice these are separate thread pools or concurrency limits per dependency, separate database connection pools, separate queues or consumers for different tenants, CPU and memory limits in Kubernetes. Graceful degradation is the next step: when a non-critical part is unavailable, the system keeps doing the essential job without it.
Навіщо. Без перегородок повільний сервіс рекомендацій забирає всі потоки веб-сервера, і вже не відкривається навіть кошик, хоча платежі й склад здорові. З перегородками рекомендацій просто немає на сторінці, а покупка працює. Для бізнесу різниця між «сторінка без блоку» і «сайт лежить» вимірюється грошима.
Why. Without partitions a slow recommendations service takes every web server thread and even the cart stops opening, although payments and stock are healthy. With partitions the page simply lacks recommendations while purchasing works. For the business, the difference between "a page without a widget" and "the site is down" is measured in money.
Як тестувати. Спершу разом з командою класифікую залежності на критичні й некритичні і фіксую очікувану поведінку для кожної відмови — це матриця деградації. Далі навантажувальний тест на звичайному профілі, і під час нього одна некритична залежність стає повільною (затримка 10 с через Toxiproxy чи WireMock). Критерії: p95 і помилки оформлення замовлення не змінились; пул рекомендацій заповнений, а зайві виклики відхиляються (у Resilience4j це BulkheadFullException) і рахуються метрикою; сторінка рендериться без блоку, а не з помилкою. Потім те саме з повною відмовою залежності й з кількома одночасно.
How to test. First, together with the team, I classify dependencies as critical and non-critical and record the expected behaviour for each failure — the degradation matrix. Then a load test on the normal profile, during which one non-critical dependency becomes slow (a 10 s delay via Toxiproxy or WireMock). Criteria: checkout p95 and error rate do not change; the recommendations pool is full and extra calls are rejected (a BulkheadFullException in Resilience4j) and counted by a metric; the page renders without the widget rather than with an error. Then the same with the dependency completely down, and with several at once.
Підводні камені. Перегородку ставлять у коді, але спільним лишається пул з'єднань до бази чи HTTP-клієнт, і збій протікає через нього. Фолбек некритичної частини сам ходить у критичну, наприклад у ту саму базу, і перевантажує її. Деградацію ніхто не бачить: без метрики й алерту блок може тижнями бути порожнім.
Pitfalls. The partition is built in code, but the database connection pool or the HTTP client stays shared, and the failure leaks through it. The fallback for a non-critical part itself calls a critical one, say the same database, and overloads it. Nobody notices the degradation: without a metric and an alert the widget can stay empty for weeks.
Уточнення. «Як обрати розмір перегородки?» — з вимірювань: пропускна здатність залежності, помножена на її типовий час відповіді, плюс запас; і перевірити, що сума перегородок не більша за ресурси сервісу.
Follow-up. "How do you size a bulkhead?" From measurements: the dependency's throughput multiplied by its typical response time, plus headroom; and check that the partitions together do not exceed the service's resources.
Product page service (200 потоків)
+----------------+---------------+---------------+
| pricing+stock | reviews | recommendations|
| 120 викликів | 20 викликів | 20 викликів |
| критичне | некритичне | некритичне |
+-------+--------+-------+-------+-------+--------+
v v v
Pricing, Stock Reviews svc Reco svc (повільний)
Матриця деградації
відмова | що бачить користувач | критерій тесту
recommendations | сторінка без блоку | p95 без змін
reviews | «відгуки завантажуються» | 0 помилок 5xx
stock | «наявність уточнюється», | замовлення не
| кнопка купити вимкнена | створюється
Product page service (200 threads)
+----------------+---------------+----------------+
| pricing+stock | reviews | recommendations|
| 120 calls | 20 calls | 20 calls |
| critical | non-critical | non-critical |
+-------+--------+-------+-------+-------+--------+
v v v
Pricing, Stock Reviews svc Reco svc (slow)
Degradation matrix
failure | what the user sees | test criterion
recommendations | page without the widget | p95 unchanged
reviews | "reviews are loading" | zero 5xx
stock | "checking availability", | no order is
| buy button disabled | created
resilience4j:
bulkhead:
instances:
recommendations:
maxConcurrentCalls: 20
maxWaitDuration: 0 # не чекати, одразу відмова
reviews:
maxConcurrentCalls: 20
maxWaitDuration: 50ms
resilience4j:
bulkhead:
instances:
recommendations:
maxConcurrentCalls: 20
maxWaitDuration: 0 # don't wait, reject at once
reviews:
maxConcurrentCalls: 20
maxWaitDuration: 50ms
maxConcurrentCalls задає розмір перегородки, а maxWaitDuration: 0 означає, що зайвий виклик одразу отримує відмову замість черги.maxConcurrentCalls sets the size of the partition, and maxWaitDuration: 0 means an extra call is rejected immediately instead of queueing.Що це. Blue-green — два однакові оточення: blue обслуговує весь трафік, green отримує нову версію. Коли green перевірене, балансувальник перемикає трафік цілком, а blue лишається гарячим резервом для миттєвого відкату. Canary — поступовий реліз у тому самому оточенні: спершу нову версію бачать 1–5% запитів, потім 25%, 50% і 100%, а між кроками система порівнює метрики канарки з базовою версією.
What it is. Blue-green means two identical environments: blue serves all traffic and green gets the new version. Once green is verified, the load balancer switches all traffic over, and blue stays as a hot standby for an instant rollback. Canary is a gradual release within the same environment: first 1–5% of requests see the new version, then 25%, 50% and 100%, and between steps the system compares the canary's metrics with the baseline version.
Навіщо. Обидва підходи зменшують ризик релізу, але по-різному. Blue-green дає швидкий повний відкат і можливість протестувати нову версію на продакшн-інфраструктурі до перемикання, зате потребує подвійних ресурсів, і всі користувачі переходять одночасно. Canary обмежує кількість людей, які побачать баг, і ловить проблеми, що проявляються лише під реальним трафіком, але реліз триває довше, а дві версії працюють паралельно годинами.
Why. Both reduce release risk, in different ways. Blue-green gives a fast full rollback and a chance to test the new version on production infrastructure before the switch, but it needs double resources and all users move at once. Canary limits how many people see a bug and catches problems that only appear under real traffic, but the release takes longer and two versions run side by side for hours.
Як тестувати. Для blue-green: смоук-набір проти green через внутрішній маршрут до перемикання; перевірка, що сесії й кошики переживають перемикання; репетиція відкату з таймером. Для canary: критерії аналізу погоджені заздалегідь (частка помилок і p95 канарки не гірші за базову версію на задану межу); користувач прив'язаний до версії, щоб не стрибати між ними; трафіку на кроці досить для статистики. Для обох: схема бази сумісна зі старою й новою версією, а формат повідомлень у черзі читають обидві.
How to test. For blue-green: a smoke suite against green through an internal route before the switch; a check that sessions and carts survive the switch; a timed rollback rehearsal. For canary: analysis criteria agreed in advance (the canary's error rate and p95 no worse than the baseline by a set margin); users pinned to a version so they do not bounce between them; enough traffic at each step for statistics. For both: the database schema is compatible with the old and the new version, and both can read the message format in the queue.
Підводні камені. Відкат коду можливий, а відкат міграції бази — ні, тому без expand/contract blue-green дає лише ілюзію безпеки. Канарку порівнюють з абсолютним порогом, а не з базовою версією в той самий час, і пропускають регрес. Фонові задачі й консюмери нової версії запускаються на 100% з першого кроку.
Pitfalls. The code can be rolled back, but a database migration cannot, so without expand/contract blue-green only gives an illusion of safety. The canary is compared with an absolute threshold instead of the baseline at the same time, and a regression slips through. Background jobs and consumers of the new version run at 100% from the first step.
Уточнення. «Хто вирішує відкат канарки?» — у зрілій команді автомат за метриками (Argo Rollouts, Flagger), а людина лише затверджує винятки.
Follow-up. "Who decides to roll back a canary?" In a mature team an automated analysis of metrics (Argo Rollouts, Flagger), with a human only approving exceptions.
apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
name: checkout
spec:
strategy:
canary:
steps:
- setWeight: 5
- pause: { duration: 10m }
- analysis: # помилки й p95 проти базової версії
templates:
- templateName: error-rate-and-latency
- setWeight: 25
- pause: { duration: 30m }
- setWeight: 50
- pause: { duration: 30m }
- setWeight: 100 # провал аналізу = автоматичний відкат
apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
name: checkout
spec:
strategy:
canary:
steps:
- setWeight: 5
- pause: { duration: 10m }
- analysis: # errors and p95 against the baseline
templates:
- templateName: error-rate-and-latency
- setWeight: 25
- pause: { duration: 30m }
- setWeight: 50
- pause: { duration: 30m }
- setWeight: 100 # a failed analysis = automatic rollback
setWeight задає частку трафіку на нову версію, pause тримає її на цьому рівні, поки збираються метрики, а крок analysis запускає шаблон, який порівнює помилки й затримку. Якщо аналіз провалено, Rollout перериває реліз і повертає трафік на стабільну версію. Для QA цей файл — місце, де записані критерії приймання релізу.setWeight sets the share of traffic on the new version, pause holds it there while metrics accumulate, and the analysis step runs a template that compares errors and latency. If the analysis fails, the Rollout aborts the release and sends traffic back to the stable version. For QA this file is where the release acceptance criteria are written down.Що це. Feature flag — умова в коді, значення якої приходить з конфігурації чи сервісу прапорців, тож функцію можна ввімкнути або вимкнути без деплою. Типи різняться життям: release flag ховає незавершену функцію і живе тижні; ops flag, або kill switch, вимикає важку функцію під час аварії і живе роками; experiment flag розподіляє користувачів між варіантами A/B; permission flag відкриває функцію окремим клієнтам чи тарифам.
What it is. A feature flag is a condition in code whose value comes from configuration or a flag service, so a feature can be switched on or off without a deploy. The types differ in lifetime: a release flag hides an unfinished feature and lives for weeks; an ops flag, or kill switch, turns off a heavy feature during an incident and lives for years; an experiment flag splits users between A/B variants; a permission flag opens a feature to specific customers or plans.
Навіщо. Прапорці відділяють деплой від релізу: код у продакшні, але ввімкнений лише для внутрішніх користувачів чи 5% аудиторії, а вимкнути його можна за секунди. Для тестування це і перевага, і борг: кожен прапорець подвоює кількість можливих станів системи, а старий прапорець, який ніхто не прибрав, — це гілка коду, яку ніхто вже не тестує.
Why. Flags separate deployment from release: the code is in production but enabled only for internal users or 5% of the audience, and it can be turned off in seconds. For testing this is both an advantage and a debt: every flag doubles the number of possible system states, and an old flag nobody removed is a code branch nobody tests any more.
Як тестувати. Кожен прапорець — щонайменше два прогони: увімкнений і вимкнений, причому вимкнений стан має збігатися з поточним продакшном. Тест сам встановлює потрібне значення для свого користувача через API чи провайдера, а не покладається на те, що хтось увімкнув його на стенді. Комбінації перевіряю лише для прапорців, які зачіпають ту саму функцію, а решту покриває pairwise. Окремі кейси: сервіс прапорців недоступний — застосунок бере безпечне значення за замовчуванням; відсоткове розгортання стабільне для одного користувача між запитами; вимкнення посеред сесії не ламає вже розпочатий процес.
How to test. Every flag means at least two runs: on and off, and the off state must match current production. The test sets the value for its own user through an API or the provider instead of relying on someone having switched it on in the environment. I check combinations only for flags that touch the same feature and cover the rest with pairwise. Separate cases: the flag service is down — the app falls back to a safe default; a percentage rollout is stable for one user across requests; switching off mid-session does not break a process already started.
Підводні камені. Глобальне перемикання прапорця на спільному стенді ламає чужі тести. Кеш значень у клієнті затримує перемикання kill switch на хвилини. Прапорці не прибирають, і за рік їх сотні. Допомагає власник і дата видалення для кожного release flag.
Pitfalls. Flipping a flag globally on a shared environment breaks other people's tests. Client-side caching of values delays a kill switch by minutes. Flags are never removed, and after a year there are hundreds of them. An owner and a removal date for every release flag help.
Уточнення. «Як перевірити сам kill switch?» — регулярно вимикати його на стенді й вимірювати, за скільки секунд функція справді зникає в усіх подів.
Follow-up. "How do you test the kill switch itself?" Regularly flip it in a test environment and measure how many seconds it takes for the feature to disappear on every pod.
import { test, expect } from '@playwright/test';
for (const enabled of [true, false]) {
test('checkout, new-address-form=' + enabled, async ({ page, request }) => {
// прапорець лише для свого тестового користувача
await request.put('/qa/flags/new-address-form', {
data: { userId: 'qa-user-17', enabled },
});
await page.goto('/checkout');
const form = page.getByTestId('address-form-v2');
if (enabled) {
await expect(form).toBeVisible();
} else {
await expect(form).toHaveCount(0); // як у поточному проді
}
await page.getByRole('button', { name: 'Pay' }).click();
await expect(page.getByText('Order confirmed')).toBeVisible();
});
}
import { test, expect } from '@playwright/test';
for (const enabled of [true, false]) {
test('checkout, new-address-form=' + enabled, async ({ page, request }) => {
// the flag is set only for our own test user
await request.put('/qa/flags/new-address-form', {
data: { userId: 'qa-user-17', enabled },
});
await page.goto('/checkout');
const form = page.getByTestId('address-form-v2');
if (enabled) {
await expect(form).toBeVisible();
} else {
await expect(form).toHaveCount(0); // same as current production
}
await page.getByRole('button', { name: 'Pay' }).click();
await expect(page.getByText('Order confirmed')).toBeVisible();
});
}
for створює два окремі тести з різними назвами, тож у звіті видно, який стан прапорця впав. request.put звертається до тестового ендпойнта сервісу прапорців (його назва тут умовна) і вмикає прапорець лише для одного користувача, щоб не заважати паралельним тестам. Перевірка форми залежить від стану, а фінальна перевірка замовлення однакова: бізнес-результат не повинен залежати від прапорця.for loop creates two separate tests with different names, so the report shows which flag state failed. request.put calls a test endpoint of the flag service (its name here is illustrative) and turns the flag on for one user only, so parallel tests are not affected. The form assertion depends on the state, while the final order assertion is the same: the business outcome must not depend on the flag.Що це. Тестування в production — не відмова від стендів, а доповнення до них: частина перевірок має сенс лише там, де справжні дані, мережа, інтеграції та навантаження. Сюди входять смоук після кожного деплою, синтетичний моніторинг (скрипт раз на кілька хвилин проходить ключовий сценарій), dark launch (новий код виконується, але результат не показується користувачу), shadow traffic (копія реальних запитів іде на нову версію, відповіді відкидаються) і canary як експеримент на частині трафіку.
What it is. Testing in production does not replace test environments, it complements them: some checks only make sense where the real data, network, integrations and load are. It covers a smoke suite after every deploy, synthetic monitoring (a script walks through a key journey every few minutes), dark launches (new code runs but its result is not shown to the user), shadow traffic (a copy of real requests goes to the new version and its responses are discarded) and a canary as an experiment on part of the traffic.
Навіщо. Staging ніколи не буде точною копією: інші обсяги даних, мок платіжного провайдера, інші сертифікати, DNS і CDN. Найдорожчі інциденти часто виникають саме на стику з реальним світом. Синтетика помічає зламаний логін о третій ночі раніше за користувачів, а shadow traffic показує, як нова версія поводиться з реальними, а не вигаданими запитами.
Why. Staging will never be an exact copy: different data volumes, a mocked payment provider, different certificates, DNS and CDN. The most expensive incidents often happen exactly where the system meets the real world. Synthetic checks spot a broken login at three in the morning before users do, and shadow traffic shows how the new version handles real rather than invented requests.
Як тестувати безпечно. Тестові акаунти позначені в базі й виключені з аналітики, білінгу, розсилок і звітів. Синтетичні запити мають заголовок на кшталт X-Synthetic: true, щоб їх можна було відфільтрувати в метриках. Перевірки здебільшого лише читають; якщо пишуть, то в ізольований простір і самі за собою прибирають. Платежі йдуть через спеціальний мерчант або з миттєвим поверненням. Shadow-версія не має побічних ефектів: листи, списання й повідомлення в черги для неї замінені заглушками. Є kill switch і ліміт частоти для самих перевірок.
How to test safely. Test accounts are flagged in the database and excluded from analytics, billing, mailings and reports. Synthetic requests carry a header such as X-Synthetic: true so they can be filtered out of metrics. Checks mostly only read; if they write, it is into an isolated space and they clean up after themselves. Payments go through a dedicated merchant or are refunded immediately. The shadow version has no side effects: emails, charges and queue messages are stubbed out for it. There is a kill switch and a rate limit for the checks themselves.
Підводні камені. Тестові замовлення потрапляють у звіти для бізнесу або на склад. Shadow-версія пише в справжню базу чи надсилає клієнтам листи. Синтетика перевіряє лише код 200, а не те, що сторінка справді показала кошик.
Pitfalls. Test orders end up in business reports or in the warehouse. The shadow version writes to the real database or emails customers. Synthetic checks only verify a 200 status, not that the page actually showed the cart.
Уточнення. «Хто реагує на падіння синтетики?» — вона має бути підключена до того самого алертингу й on-call, що й інші сигнали, інакше це ще один дашборд, на який ніхто не дивиться.
Follow-up. "Who reacts when a synthetic check fails?" It has to be wired into the same alerting and on-call as other signals, otherwise it is just another dashboard nobody watches.
import { test, expect } from '@playwright/test';
test.use({ extraHTTPHeaders: { 'X-Synthetic': 'true' } });
test('synthetic: login and cart', async ({ page }) => {
const started = Date.now();
await page.goto('https://shop.example.com/login');
// окремий акаунт з is_test = true, виключений з аналітики
await page.getByLabel('Email').fill(process.env.SYNTH_USER);
await page.getByLabel('Password').fill(process.env.SYNTH_PASS);
await page.getByRole('button', { name: 'Sign in' }).click();
await page.goto('https://shop.example.com/cart');
// перевіряємо зміст, а не лише код 200
await expect(page.getByTestId('cart-items')).toBeVisible();
expect(Date.now() - started).toBeLessThan(5000);
});
import { test, expect } from '@playwright/test';
test.use({ extraHTTPHeaders: { 'X-Synthetic': 'true' } });
test('synthetic: login and cart', async ({ page }) => {
const started = Date.now();
await page.goto('https://shop.example.com/login');
// a dedicated account with is_test = true, excluded from analytics
await page.getByLabel('Email').fill(process.env.SYNTH_USER);
await page.getByLabel('Password').fill(process.env.SYNTH_PASS);
await page.getByRole('button', { name: 'Sign in' }).click();
await page.goto('https://shop.example.com/cart');
// check the content, not just a 200 status
await expect(page.getByTestId('cart-items')).toBeVisible();
expect(Date.now() - started).toBeLessThan(5000);
});
test.use з extraHTTPHeaders додає маркер до кожного запиту, тож бекенд і аналітика можуть відрізнити синтетику від людей. Облікові дані беруться з секретів оточення, а не з коду. Твердження перевіряють зміст сторінки й загальний час сценарію; такий файл запускають за розкладом із CI чи платформи моніторингу, і його падіння йде в алертинг.test.use with extraHTTPHeaders adds a marker to every request, so the backend and analytics can tell synthetic traffic from people. Credentials come from environment secrets, not from the code. The assertions check the page content and the total journey time; such a file runs on a schedule from CI or a monitoring platform, and its failure goes to alerting.Контекст і ризики. Платіжний сервіс приймає запит від checkout, створює платіж, викликає провайдера (PSP), отримує від нього вебхуки про результат і записує проводки в ledger. Головні ризики: подвійне списання, втрачений платіж (гроші списані, замовлення не оплачене), неправильна сума чи валюта, витік даних картки, недоступність під час розпродажу. Від цих ризиків і будую стратегію, а не від списку ендпойнтів.
Context and risks. A payment service takes a request from checkout, creates a payment, calls the provider (PSP), receives webhooks with the result and writes entries to a ledger. The main risks: a double charge, a lost payment (money taken, order unpaid), a wrong amount or currency, a card data leak, and downtime during a sale. I build the strategy from these risks, not from a list of endpoints.
Стратегія по рівнях. Юніт-тести на гроші: суми в мінорних одиницях, округлення, валюти з різною кількістю знаків (JPY без копійок, KWD і BHD з трьома знаками). Компонентні тести сервісу з WireMock замість PSP, включно зі сценаріями таймауту й помилок. Контрактні тести з checkout і з обробником вебхуків. Інтеграція з пісочницею провайдера на стенді. E2E лише для кількох головних шляхів, зокрема 3-D Secure. Навантаження на профіль розпродажу. У проді — щоденна звірка (reconciliation) ledger із звітом провайдера та алерти на розбіжності.
Strategy by layer. Unit tests on money: amounts in minor units, rounding, currencies with different decimals (JPY with none, KWD and BHD with three). Component tests of the service with WireMock instead of the PSP, including timeout and error scenarios. Contract tests with checkout and with the webhook handler. Integration with the provider's sandbox in a test environment. E2E only for a few main paths, including 3-D Secure. Load tests on a sale profile. In production — daily reconciliation of the ledger against the provider report, with alerts on mismatches.
Ключові тест-кейси. Подвійний клік і повтор запиту з тим самим Idempotency-Key дають одне списання. Таймаут від PSP з невідомим результатом: платіж отримує статус pending, сервіс з'ясовує результат запитом статусу чи вебхуком і не повторює списання з новим ключем. Вебхук приходить двічі, раніше за відповідь API або в неправильному порядку (succeeded раніше за processing) — фінальний стан правильний. Вебхук з невалідним підписом відхиляється. Часткові й повні повернення, повернення більше за суму платежу. PSP недоступний — circuit breaker, зрозуміле повідомлення, замовлення не втрачається.
Key test cases. A double click and a retried request with the same Idempotency-Key result in one charge. A PSP timeout with an unknown outcome: the payment gets the pending status, the service learns the result via a status query or a webhook and never re-charges with a new key. A webhook arrives twice, before the API response, or out of order (succeeded before processing) — the final state is correct. A webhook with an invalid signature is rejected. Partial and full refunds, a refund larger than the payment. The PSP is down — circuit breaker, a clear message, the order is not lost.
Підводні камені. Номер картки чи CVV потрапляє в логи, трейси або тестові дампи й розширює зону PCI DSS. Пісочниця провайдера поводиться ідеальніше за продакшн, тому таймаути й дублі треба моделювати власними стабами.
Pitfalls. A card number or CVV ends up in logs, traces or test dumps and widens the PCI DSS scope. The provider sandbox behaves more perfectly than production, so timeouts and duplicates have to be modelled with your own stubs.
Уточнення. «Що робити з платежем у pending назавжди?» — окремий процес дозвіряє такі платежі з провайдером за розкладом, а тест перевіряє, що жоден не лишається без фінального статусу довше за погоджений час.
Follow-up. "What about a payment stuck in pending forever?" A separate job reconciles such payments with the provider on a schedule, and a test checks that none stays without a final status longer than the agreed time.
Checkout Payment svc PSP
| POST /payments | |
| Idempotency-Key | |
|---------------->| створити (pending) |
| |--- charge ---------->|
| | X таймаут? | результат невідомий
| 202 pending | |
|<----------------|<-- webhook ----------| дубль? порядок? підпис?
| | ledger: 1 проводка |
| | |
| reconciliation (щодня) <------| звіт PSP = ledger?
Checkout Payment svc PSP
| POST /payments | |
| Idempotency-Key | |
|---------------->| create (pending) |
| |--- charge ---------->|
| | X timeout? | outcome unknown
| 202 pending | |
|<----------------|<-- webhook ----------| duplicate? order? signature?
| | ledger: 1 entry |
| | |
| reconciliation (daily) <------| PSP report = ledger?
Ризик | Рівень | Перевірка
подвійне списання | компонент, E2E | той самий ключ -> 1 charge
невідомий результат | компонент | таймаут PSP -> pending -> fix
вебхуки | контракт, комп. | дубль, порядок, підпис
сума і валюта | юніт | JPY 0, UAH 2, KWD 3 знаки
втрачений платіж | прод-моніторинг | reconciliation, алерт
дані картки | безпека, логи | немає PAN/CVV у логах
розпродаж | навантаження | p95, 0 дублів під піком
Risk | Layer | Check
double charge | component, E2E | same key -> 1 charge
unknown outcome | component | PSP timeout -> pending -> fix
webhooks | contract, comp. | duplicate, order, signature
amount and currency | unit | JPY 0, UAH 2, KWD 3 decimals
lost payment | prod monitoring | reconciliation, alert
card data | security, logs | no PAN/CVV in logs
sale peak | load | p95, 0 duplicates at peak
Що це. Observability — здатність зрозуміти внутрішній стан системи ззовні, не додаючи нового коду під кожне питання. Її тримають три типи телеметрії. Логи — окремі події з контекстом: «замовлення 42 відхилено, причина — картку заблоковано». Метрики — числа в часі: кількість запитів, частка помилок, p95 затримки, довжина черги. Трейси — дерево спанів одного запиту через усі сервіси з тривалістю кожного кроку.
What it is. Observability is the ability to understand a system’s internal state from the outside without shipping new code for every question. Three kinds of telemetry carry it. Logs are individual events with context: “order 42 rejected, reason: card blocked”. Metrics are numbers over time: request count, error ratio, p95 latency, queue depth. Traces are the tree of spans for one request across all services, with the duration of every step.
Навіщо. Моніторинг відповідає на заздалегідь відомі питання («чи живий сервіс?»), а observability дозволяє поставити нове питання під час інциденту чи тесту. Для QA кожен сигнал має свою роль: метрика каже, що погіршилось і наскільки масово, трейс показує, де саме в ланцюжку втрачено час, а лог пояснює, чому конкретний запит завершився саме так.
Why. Monitoring answers questions known in advance (“is the service up?”), while observability lets you ask a new question during an incident or a test run. For QA each signal has its own role: a metric tells what got worse and how widespread it is, a trace shows where in the chain the time was lost, and a log explains why one particular request ended the way it did.
Як тестувати. Я використовую телеметрію і як інструмент, і як об’єкт перевірки. Як інструмент: після падіння автотесту відкриваю трейс за його trace id, а не гадаю за скріншотом. Під навантажувальним тестом дивлюсь на метрики сервера, а не лише на час відповіді клієнта. Як об’єкт: перевіряю, що помилка оплати пише лог рівня ERROR з кодом причини і без номера картки, що лічильник payments_failed_total зростає, а в трейсі є спан виклику провайдера зі статусом помилки.
How to test. I treat telemetry both as a tool and as something to test. As a tool: when an automated test fails, I open the trace by its trace id instead of guessing from a screenshot. During a load test I watch server-side metrics, not only client response time. As a test target: I check that a failed payment writes an ERROR log with a reason code and no card number, that the payments_failed_total counter goes up, and that the trace contains a span for the provider call marked as an error.
Підводні камені. Логи без структури (вільний текст) важко шукати, а логи з персональними даними — порушення вимог захисту даних. Метрики з мітками на кшталт user_id вибухають за кардинальністю і кладуть систему моніторингу. Семплювання трейсів може відкинути саме той рідкісний повільний запит, який ти шукаєш, тому для тестових середовищ часто вмикають повне семплювання.
Pitfalls. Unstructured free-text logs are hard to search, and logs containing personal data break data protection rules. Metrics labelled with something like user_id explode in cardinality and take the monitoring system down. Trace sampling may drop exactly the rare slow request you are hunting, which is why test environments often run with full sampling.
Уточнення. Питання «з чого почнеш розслідування?» має хорошу відповідь: з метрики, щоб оцінити масштаб, далі до трейсу типового проблемного запиту, і лише потім до логів конкретного сервісу. Такий порядок економить час, бо логів завжди найбільше.
Follow-up. “Where do you start an investigation?” has a good answer: with a metric to see the scale, then a trace of a typical failing request, and only then the logs of one specific service. That order saves time, because logs are always the largest pile.
МЕТРИКА (що і наскільки масово)
http_requests_total{route="/checkout",code="504"}
частка 504 зросла з 0.1% до 7% о 14:02
ТРЕЙС (де) trace_id=7d3f...a91c
POST /checkout 3012 ms
├─ order-service 3005 ms
│ ├─ SELECT cart 12 ms
│ └─ payment-service 2990 ms <- тут
│ └─ HTTP bank-api 2985 ms timeout
└─ gateway: 504 після 3 s
ЛОГ (чому)
{"level":"ERROR","service":"payment-service",
"trace_id":"7d3f...a91c","msg":"bank-api timeout",
"reason":"READ_TIMEOUT","timeout_ms":2980}
METRIC (what and how widespread)
http_requests_total{route="/checkout",code="504"}
504 ratio rose from 0.1% to 7% at 14:02
TRACE (where) trace_id=7d3f...a91c
POST /checkout 3012 ms
├─ order-service 3005 ms
│ ├─ SELECT cart 12 ms
│ └─ payment-service 2990 ms <- here
│ └─ HTTP bank-api 2985 ms timeout
└─ gateway: 504 after 3 s
LOG (why)
{"level":"ERROR","service":"payment-service",
"trace_id":"7d3f...a91c","msg":"bank-api timeout",
"reason":"READ_TIMEOUT","timeout_ms":2980}
route і code показує сплеск 504. Трейс розкладає запит на спани з тривалістю, і видно, що майже весь час з’їв виклик банку. Лог із тим самим trace_id називає причину — таймаут читання. Спільний trace_id і зшиває три сигнали між собою.route and code labels shows the 504 spike. The trace breaks the request into spans with durations, and you can see the bank call ate almost all the time. The log with the same trace_id names the cause — a read timeout. The shared trace_id is what stitches the three signals together.Що це. Distributed tracing збирає один запит у дерево спанів. Trace id — ідентифікатор усього запиту, span id — окремого кроку, а parent id зв’язує крок із батьком. За стандартом W3C Trace Context контекст передається заголовком traceparent у форматі версія-traceid-parentid-прапорці: 32 шістнадцяткові символи на trace id, 16 на span id і прапорець семплювання. OpenTelemetry SDK сам читає цей заголовок, створює дочірні спани і передає контекст далі.
What it is. Distributed tracing assembles one request into a tree of spans. The trace id identifies the whole request, a span id identifies one step, and the parent id links a step to its parent. Under the W3C Trace Context standard the context travels in the traceparent header as version-traceid-parentid-flags: 32 hex characters for the trace id, 16 for the span id, and a sampling flag. The OpenTelemetry SDK reads that header, creates child spans and passes the context on.
Навіщо. Correlation id у логах існував і до трейсингу, але трейс додає структуру: видно, які виклики йшли паралельно, де чекали на базу і який саме сервіс повернув помилку. Для QA це різниця між «тест упав з 500» і «тест упав, бо inventory-service отримав таймаут від Redis на 2 секунди». Якщо ж ланцюжок контексту десь рветься, трейс розпадається на шматки, і розслідування знову стає вгадуванням.
Why. A correlation id in logs existed before tracing, but a trace adds structure: you see which calls ran in parallel, where the database wait was, and which service actually returned the error. For QA that is the difference between “the test failed with a 500” and “the test failed because inventory-service hit a 2-second Redis timeout”. If the context chain breaks somewhere, the trace falls apart into fragments and the investigation goes back to guessing.
Як тестувати. Пропагацію варто перевіряти автотестом. Тест генерує власний trace id, шле запит із заголовком traceparent, а потім шукає цей id у логах кожного сервісу ланцюжка або в бекенді трейсів. Особливу увагу — асинхронним межам: при публікації в Kafka чи RabbitMQ контекст має їхати в заголовках повідомлення, а споживач має продовжити той самий трейс. Окремо перевіряю, що кожен рядок структурованого логу містить поля trace_id і span_id.
How to test. Propagation is worth checking with an automated test. The test generates its own trace id, sends a request with a traceparent header, and then looks for that id in the logs of every service in the chain or in the tracing backend. Pay special attention to asynchronous boundaries: when publishing to Kafka or RabbitMQ the context must ride in the message headers, and the consumer must continue the same trace. I also check that every structured log line carries trace_id and span_id fields.
Підводні камені. Класичні розриви: власний HTTP-клієнт без інструментування, пул потоків, куди контекст не передали, черги й cron-задачі. Gateway, який генерує новий trace id замість того, щоб прийняти вхідний, ламає зв’язок із тестом. Ще одна пастка — семплювання: якщо прапорець у traceparent дорівнює 00, трейс може не записатись взагалі, і тест марно його шукатиме.
Pitfalls. Classic breaks are a hand-rolled HTTP client without instrumentation, a thread pool that never received the context, queues and cron jobs. A gateway that mints a fresh trace id instead of accepting the incoming one cuts the link to your test. Another trap is sampling: if the flag in traceparent is 00, the trace may never be recorded, and the test will search for it in vain.
Уточнення. Можуть спитати про tracestate і baggage. tracestate несе вендор-специфічні дані трейсингу, а W3C Baggage — довільні пари ключ-значення на кшталт tenant id. Baggage їде в кожен сервіс, тому класти туди секрети чи персональні дані не можна.
Follow-up. You may be asked about tracestate and baggage. tracestate carries vendor-specific tracing data, while W3C Baggage carries arbitrary key-value pairs such as a tenant id. Baggage travels to every service, so secrets and personal data must never go there.
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
| | | |
| trace-id (32 hex) parent-id (16) прапорці: 01 = семплюється
версія
{"ts":"2026-09-29T10:15:02Z","level":"INFO","service":"billing",
"trace_id":"4bf92f3577b34da6a3ce929d0e0e4736",
"span_id":"b7ad6b7169203331","msg":"invoice created","order":42}
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
| | | |
| trace-id (32 hex) parent-id (16) flags: 01 = sampled
version
{"ts":"2026-09-29T10:15:02Z","level":"INFO","service":"billing",
"trace_id":"4bf92f3577b34da6a3ce929d0e0e4736",
"span_id":"b7ad6b7169203331","msg":"invoice created","order":42}
const crypto = require('node:crypto');
test('trace id доходить до billing через Kafka', async () => {
const traceId = crypto.randomBytes(16).toString('hex');
const parent = crypto.randomBytes(8).toString('hex');
const res = await fetch(API + '/orders', {
method: 'POST',
headers: {
'content-type': 'application/json',
traceparent: '00-' + traceId + '-' + parent + '-01',
},
body: JSON.stringify({ sku: 'SKU-1', qty: 1 }),
});
expect(res.status).toBe(201);
// billing читає подію з черги; чекаємо його лог із тим самим id
const lines = await eventually(() =>
queryLogs('{service="billing"} |= "' + traceId + '"'), 15000);
expect(lines.length).toBeGreaterThan(0);
});
const crypto = require('node:crypto');
test('trace id reaches billing through Kafka', async () => {
const traceId = crypto.randomBytes(16).toString('hex');
const parent = crypto.randomBytes(8).toString('hex');
const res = await fetch(API + '/orders', {
method: 'POST',
headers: {
'content-type': 'application/json',
traceparent: '00-' + traceId + '-' + parent + '-01',
},
body: JSON.stringify({ sku: 'SKU-1', qty: 1 }),
});
expect(res.status).toBe(201);
// billing consumes the event; wait for its log with the same id
const lines = await eventually(() =>
queryLogs('{service="billing"} |= "' + traceId + '"'), 15000);
expect(lines.length).toBeGreaterThan(0);
});
traceparent на чотири поля, а рядок логу показує, як OpenTelemetry-інструментований логер додає trace_id і span_id. У тесті trace id генерується випадково, тож його легко знайти. queryLogs — обгортка над API лог-системи, тут із запитом LogQL для Loki, а eventually повторює пошук до дедлайну, бо споживач обробляє подію асинхронно.traceparent into its four fields, and the log line shows how an OpenTelemetry-instrumented logger adds trace_id and span_id. In the test the trace id is random, so it is easy to find. queryLogs wraps the log system API, here with a LogQL query for Loki, and eventually retries the search until a deadline because the consumer handles the event asynchronously.Що це. SLI (Service Level Indicator) — виміряний показник, зазвичай частка «добрих» подій серед усіх: успішні запити / усі запити, або запити швидші за 300 мс / усі запити. SLO (Service Level Objective) — ціль над цим показником за вікно часу: «99.9% запитів успішні за ковзні 30 днів». SLA (Service Level Agreement) — контракт із клієнтом, де записано нижчий поріг і наслідки: повернення коштів чи кредити. Тож «99.9%» саме по собі не є SLI — це ціль, тобто SLO.
What it is. An SLI (Service Level Indicator) is a measured value, usually the share of “good” events among all events: successful requests / all requests, or requests faster than 300 ms / all requests. An SLO (Service Level Objective) is a target on that indicator over a time window: “99.9% of requests succeed over a rolling 30 days”. An SLA (Service Level Agreement) is a contract with a customer that states a lower threshold and consequences such as refunds or credits. So “99.9%” on its own is not an SLI — it is a target, an SLO.
Навіщо. Error budget — це 100% мінус SLO, тобто скільки поганих подій дозволено. За SLO 99.9% на 30 днів бюджет — 0.1% часу, це 43.2 хвилини, або при 10 мільйонах запитів — 10 000 помилкових. Поки бюджет є, команда випускає фічі швидко; коли його спалено, пріоритет переходить на стабільність, а ризиковані релізи чекають. Це перетворює суперечку «швидкість чи якість» на домовленість із числами.
Why. The error budget is 100% minus the SLO, i.e. how many bad events are allowed. With a 99.9% SLO over 30 days the budget is 0.1% of the time, 43.2 minutes, or with 10 million requests, 10,000 failed ones. While budget remains, the team ships features fast; once it is burnt, priority shifts to stability and risky releases wait. That turns the “speed or quality” argument into an agreement with numbers.
Як тестувати. QA перевіряє, що SLI рахується чесно: 5xx від gateway потрапляють у знаменник, 4xx клієнта не вважаються помилкою сервісу, health checks і синтетичні запити не роздувають «добрі» події. На навантажувальному тесті пороги я беру з SLO: якщо SLO каже p95 нижче 300 мс, то саме це стає threshold у k6. Після релізу дивлюсь на burn rate — швидкість спалювання бюджету; якщо за годину згоріло кілька відсотків місячного бюджету, це сигнал до відкату.
How to test. QA checks the SLI is computed honestly: 5xx from the gateway land in the denominator, client 4xx are not counted as service failures, and health checks and synthetic calls do not inflate the good events. In a load test I take thresholds from the SLO: if the SLO says p95 under 300 ms, that becomes the k6 threshold. After a release I watch the burn rate, the speed at which the budget is consumed; if several percent of the monthly budget burns in one hour, that is a rollback signal.
Підводні камені. SLO на рівні 100% робить будь-яку зміну забороненою і все одно недосяжний. SLA не варто ставити рівним SLO: внутрішня ціль має бути суворішою, щоб команда встигла зреагувати до штрафів. І SLI, виміряний лише на сервері, не бачить проблем DNS, CDN чи мобільної мережі — іноді потрібен SLI з боку клієнта.
Pitfalls. An SLO of 100% forbids any change and is unreachable anyway. The SLA should not equal the SLO: the internal target must be stricter, so the team can react before penalties apply. And an SLI measured only on the server does not see DNS, CDN or mobile network problems — sometimes you need a client-side SLI too.
Уточнення. «Хто порушує SLA?» — лише провайдер перед клієнтом, і тоді спрацьовують фінансові наслідки. Порушення SLO — внутрішня подія: вона змінює пріоритети команди, але штрафу не несе.
Follow-up. “Who breaches an SLA?” Only the provider towards the customer, and then financial consequences kick in. Breaching an SLO is an internal event: it changes the team’s priorities but carries no penalty.
# спрощена специфікація в дусі OpenSLO
slo:
service: checkout-api
sli: # що вимірюємо
good: http_requests_total{route="/checkout",code!~"5.."}
total: http_requests_total{route="/checkout"}
objective: 0.999 # SLO: 99.9% успішних
window: 30d # ковзне вікно
alerts:
fast_burn: 14.4x за 1h # сторінкуємо чергового
slow_burn: 3x за 6h # тікет
sla:
customer_threshold: 0.995 # у контракті нижче за SLO
penalty: 10% кредиту за місяць
# бюджет: (1 - 0.999) * 30 * 24 * 60 = 43.2 хв
# або 0.001 * 10 000 000 запитів = 10 000 помилок
# simplified spec in the spirit of OpenSLO
slo:
service: checkout-api
sli: # what we measure
good: http_requests_total{route="/checkout",code!~"5.."}
total: http_requests_total{route="/checkout"}
objective: 0.999 # SLO: 99.9% successful
window: 30d # rolling window
alerts:
fast_burn: 14.4x over 1h # page the on-call
slow_burn: 3x over 6h # ticket
sla:
customer_threshold: 0.995 # contract is below the SLO
penalty: 10% monthly credit
# budget: (1 - 0.999) * 30 * 24 * 60 = 43.2 min
# or 0.001 * 10,000,000 requests = 10,000 errors
sli задає частку: добрі запити (без 5xx) поділити на всі. objective і window разом утворюють SLO. Burn rate 14.4 означає, що бюджет горить у 14.4 раза швидше за норму: така швидкість з’їдає 2% місячного бюджету за годину. Блок sla навмисно м’якший за SLO, а коментарі внизу рахують бюджет у хвилинах і в запитах.sli defines the ratio: good requests (no 5xx) divided by all. objective and window together form the SLO. A burn rate of 14.4 means the budget burns 14.4 times faster than normal, which eats 2% of the monthly budget in an hour. The sla block is deliberately looser than the SLO, and the comments at the bottom compute the budget in minutes and in requests.Що це. Доступність — частка часу (або запитів), коли система робить свою роботу. «Дев’ятки» зручно перекладати в хвилини: 99% — це 7.2 години простою на 30 днів, 99.9% — 43.2 хвилини, 99.95% — 21.6 хвилини, 99.99% — 4.32 хвилини. Кожна наступна дев’ятка в десять разів зменшує дозволений простій і зазвичай у рази збільшує вартість.
What it is. Availability is the share of time (or of requests) during which the system does its job. The “nines” are easier to grasp in minutes: 99% means 7.2 hours of downtime per 30 days, 99.9% means 43.2 minutes, 99.95% means 21.6 minutes, 99.99% means 4.32 minutes. Each extra nine cuts the allowed downtime tenfold and usually multiplies the cost.
Навіщо. Якщо запит послідовно проходить через кілька сервісів і кожен потрібен, їхні доступності множаться. П’ять сервісів по 99.9% дають 0.999 у п’ятому степені, тобто близько 99.5%, а це вже 3.6 години простою на місяць замість 43 хвилин. Надлишковість працює навпаки: дві незалежні копії по 99% разом дають 1 − 0.01 × 0.01 = 99.99%. Звідси інженерні висновки: скорочувати критичний ланцюжок і дублювати слабкі ланки.
Why. When a request passes serially through several services and each one is required, their availabilities multiply. Five services at 99.9% give 0.999 to the fifth power, about 99.5%, which is already 3.6 hours of downtime a month instead of 43 minutes. Redundancy works the other way: two independent copies at 99% each give 1 − 0.01 × 0.01 = 99.99% together. Hence the engineering conclusions: shorten the critical chain and duplicate the weak links.
Як тестувати. Тестувальник перевіряє, що реальність відповідає моделі. Я малюю критичний шлях сценарію і для кожної залежності питаю: вона справді обов’язкова, чи є fallback? Потім вимикаю необов’язкову залежність (рекомендації, відгуки) і переконуюсь, що основний сценарій живий — інакше вона фактично стоїть у послідовному ланцюжку. Для «надлишкових» пар перевіряю, що перемикання на резерв справді відбувається і скільки воно триває, бо час failover теж з’їдає бюджет.
How to test. A tester checks that reality matches the model. I draw the critical path of a scenario and for every dependency ask: is it truly required, or is there a fallback? Then I switch off an optional dependency (recommendations, reviews) and confirm the main flow still works — otherwise it is effectively sitting in the serial chain. For “redundant” pairs I verify that failover actually happens and how long it takes, because failover time also eats the budget.
Підводні камені. Формула паралельної надлишковості припускає незалежні збої, а дві репліки в одній зоні доступності, з одним конфігом і однією версією падають разом. Ще одна помилка — рахувати доступність лише за uptime процесу: сервіс може бути «піднятим», але повертати 500 на кожен другий запит. Надійніше рахувати за часткою успішних запитів.
Pitfalls. The parallel redundancy formula assumes independent failures, yet two replicas in one availability zone, with one config and one version, fail together. Another mistake is measuring availability by process uptime alone: a service can be “up” while returning 500 for every other request. Counting the share of successful requests is more reliable.
Уточнення. Якщо спитають, чи може система бути доступнішою за свою залежність, відповідь «так», але лише коли залежність не в критичному шляху: є кеш, черга з відкладеною обробкою або деградований режим, що обходить її збій.
Follow-up. If asked whether a system can be more available than its dependency, the answer is “yes”, but only when the dependency is off the critical path: there is a cache, a queue with deferred processing, or a degraded mode that routes around its failure.
Дев’ятки -> простій за 30 днів (43 200 хв)
99% -> 432 хв (7.2 год)
99.9% -> 43.2 хв
99.95% -> 21.6 хв
99.99% -> 4.32 хв
Послідовно (потрібні всі): gateway -> order -> payment -> db -> bank
A = 0.999 ^ 5 = 0.99501 -> ~99.5% -> ~216 хв простою
Паралельно (досить однієї з двох незалежних копій):
A = 1 - (1 - 0.99) * (1 - 0.99) = 1 - 0.0001 = 99.99%
Змішано: LB 99.99% -> [app x2 по 99.5%] -> db 99.95%
app-пара = 1 - 0.005^2 = 0.999975
A = 0.9999 * 0.999975 * 0.9995 = ~99.94%
Nines -> downtime per 30 days (43,200 min)
99% -> 432 min (7.2 h)
99.9% -> 43.2 min
99.95% -> 21.6 min
99.99% -> 4.32 min
Serial (all required): gateway -> order -> payment -> db -> bank
A = 0.999 ^ 5 = 0.99501 -> ~99.5% -> ~216 min downtime
Parallel (one of two independent copies is enough):
A = 1 - (1 - 0.99) * (1 - 0.99) = 1 - 0.0001 = 99.99%
Mixed: LB 99.99% -> [app x2 at 99.5%] -> db 99.95%
app pair = 1 - 0.005^2 = 0.999975
A = 0.9999 * 0.999975 * 0.9995 = ~99.94%
Що це. Контрактний тест перевіряє домовленість між двома сервісами — які запити шле клієнт (consumer) і які відповіді він очікує від постачальника (provider) — без підйому обох разом. Pact реалізує consumer-driven підхід: тест споживача ганяє свій код проти mock-сервера Pact, а той записує взаємодії у файл-контракт. Потім постачальник програє ці взаємодії проти свого справжнього застосунку і доводить, що відповідає саме так.
What it is. A contract test checks the agreement between two services — which requests the consumer sends and which responses it expects from the provider — without running both together. Pact implements the consumer-driven approach: the consumer test runs its code against a Pact mock server, which records the interactions into a contract file. The provider then replays those interactions against its real application and proves it responds exactly that way.
Навіщо. E2E-тест на спільному стенді ловить поломку інтерфейсу пізно, повільно і нестабільно. Контракт ловить її в CI того сервісу, який зламав, за секунди. Consumer-driven означає, що постачальник бачить, які поля реально використовують клієнти: поле, яке ніхто не читає, можна прибрати, а обов’язкове — ні. Pact Broker зберігає контракти й результати верифікацій, а команда can-i-deploy перед деплоєм відповідає, чи сумісна ця версія з тим, що вже є в середовищі.
Why. An E2E test on a shared environment catches an interface break late, slowly and flakily. A contract catches it in the CI of the service that broke it, within seconds. Consumer-driven means the provider sees which fields clients actually use: a field nobody reads can be removed, a required one cannot. The Pact Broker stores contracts and verification results, and the can-i-deploy command answers before a deploy whether this version is compatible with what already runs in the environment.
Як тестувати. На боці споживача пишу взаємодію з provider state («замовлення 42 існує»), запит і відповідь з матчерами за типом, а не з конкретними значеннями, і викликаю справжній API-клієнт проти mock-сервера. Контракт публікую в Broker із версією, що дорівнює git sha, і гілкою. Постачальник у своєму CI верифікує контракти, налаштовуючи стани через state handlers. Деплой обох захищено перевіркою can-i-deploy, а після деплою викликається record-deployment.
How to test. On the consumer side I write an interaction with a provider state (“order 42 exists”), a request, and a response using type matchers rather than exact values, and I call the real API client against the mock server. I publish the contract to the Broker with the git sha as the version, plus the branch. The provider verifies the contracts in its own CI, setting up states through state handlers. Deploys on both sides are guarded by can-i-deploy, and record-deployment is called after a deploy.
Підводні камені. Контракт не є функціональним тестом постачальника: він не перевіряє, що сума розрахована правильно, лише форму й семантику відповіді. Надто точні значення замість матчерів роблять контракт крихким. Ще одна поширена помилка — писати контракт на все, що повертає API, а не на те, що споживач реально читає; тоді зникає головна перевага підходу.
Pitfalls. A contract is not a functional test of the provider: it does not check that the total is calculated correctly, only the shape and meaning of the response. Exact values instead of matchers make the contract brittle. Another common mistake is writing the contract for everything the API returns rather than for what the consumer actually reads, which throws away the main advantage of the approach.
Уточнення. Альтернатива — provider-driven або bi-directional контракти, коли специфікацію OpenAPI постачальника порівнюють із очікуваннями споживача. Вони дешевші в запуску, але слабші: відповідність схемі не доводить, що реальна реалізація поводиться так само.
Follow-up. The alternative is provider-driven or bi-directional contracts, where the provider’s OpenAPI spec is compared with the consumer’s expectations. They are cheaper to start with but weaker: matching a schema does not prove the real implementation behaves the same way.
const path = require('node:path');
const { PactV3, MatchersV3 } = require('@pact-foundation/pact');
const { integer, string, decimal } = MatchersV3;
const { fetchOrder } = require('../src/orderClient');
const provider = new PactV3({
consumer: 'checkout-web',
provider: 'order-service',
dir: path.resolve(process.cwd(), 'pacts'),
});
test('читає оплачене замовлення', () => {
provider
.given('order 42 exists and is paid')
.uponReceiving('a request for order 42')
.withRequest({ method: 'GET', path: '/orders/42',
headers: { Accept: 'application/json' } })
.willRespondWith({
status: 200,
headers: { 'Content-Type': 'application/json' },
body: { id: integer(42), status: string('PAID'),
total: decimal(19.99) },
});
return provider.executeTest(async (mock) => {
const order = await fetchOrder(mock.url, 42); // справжній клієнт
expect(order.status).toBe('PAID');
});
});
const path = require('node:path');
const { PactV3, MatchersV3 } = require('@pact-foundation/pact');
const { integer, string, decimal } = MatchersV3;
const { fetchOrder } = require('../src/orderClient');
const provider = new PactV3({
consumer: 'checkout-web',
provider: 'order-service',
dir: path.resolve(process.cwd(), 'pacts'),
});
test('reads a paid order', () => {
provider
.given('order 42 exists and is paid')
.uponReceiving('a request for order 42')
.withRequest({ method: 'GET', path: '/orders/42',
headers: { Accept: 'application/json' } })
.willRespondWith({
status: 200,
headers: { 'Content-Type': 'application/json' },
body: { id: integer(42), status: string('PAID'),
total: decimal(19.99) },
});
return provider.executeTest(async (mock) => {
const order = await fetchOrder(mock.url, 42); // real client
expect(order.status).toBe('PAID');
});
});
pact-broker publish ./pacts --consumer-app-version "$GIT_SHA" \
--branch main --broker-base-url "$PACT_BROKER"
# перед деплоєм: чи сумісна версія з production?
pact-broker can-i-deploy --pacticipant checkout-web \
--version "$GIT_SHA" --to-environment production \
--broker-base-url "$PACT_BROKER"
pact-broker record-deployment --pacticipant checkout-web \
--version "$GIT_SHA" --environment production \
--broker-base-url "$PACT_BROKER"
pact-broker publish ./pacts --consumer-app-version "$GIT_SHA" \
--branch main --broker-base-url "$PACT_BROKER"
# before deploy: is this version compatible with production?
pact-broker can-i-deploy --pacticipant checkout-web \
--version "$GIT_SHA" --to-environment production \
--broker-base-url "$PACT_BROKER"
pact-broker record-deployment --pacticipant checkout-web \
--version "$GIT_SHA" --environment production \
--broker-base-url "$PACT_BROKER"
given задає provider state, який постачальник відтворить у своєму state handler. Матчери integer, string, decimal фіксують тип, а значення в дужках — лише приклад для mock-сервера. executeTest піднімає mock на випадковому порту і після тесту пише JSON-контракт у pacts/. CLI публікує контракт із версією git sha, can-i-deploy повертає ненульовий код, якщо сумісність не доведена.given sets the provider state the provider will reproduce in its state handler. The integer, string and decimal matchers fix the type, while the value in brackets is only an example for the mock server. executeTest starts the mock on a random port and afterwards writes the JSON contract to pacts/. The CLI publishes the contract under the git sha version, and can-i-deploy exits non-zero when compatibility is not proven.Що це. Мок у юніт-тесті підміняє об’єкт у пам’яті. Віртуалізація сервісу (WireMock, mountebank, Hoverfly) підміняє цілу залежність справжнім HTTP-сервером, який віддає записані або задані відповіді, зокрема повільні та помилкові. Testcontainers піднімає справжні інфраструктурні компоненти — PostgreSQL, Kafka, Redis — у Docker прямо з тесту. Ефемерне середовище на pull request — повна копія системи, яка живе годину-дві й знищується після перевірки.
What it is. A mock in a unit test replaces an object in memory. Service virtualisation (WireMock, mountebank, Hoverfly) replaces a whole dependency with a real HTTP server that returns recorded or configured responses, including slow and failing ones. Testcontainers starts real infrastructure — PostgreSQL, Kafka, Redis — in Docker straight from the test. An ephemeral per-pull-request environment is a full copy of the system that lives an hour or two and is destroyed after the checks.
Навіщо. Спільний staging має три хвороби: чужі деплої ламають твої тести, дані постійно брудні, а сторонні сервіси (банк, SMS-провайдер) недоступні або платні. Компонентний тест, де мій сервіс справжній, база з Testcontainers, а зовнішні API віртуалізовані, стабільний і швидкий. До того ж віртуальний сервіс уміє те, чого не вміє справжній: повернути 503, затримку в 5 секунд або обірвати з’єднання на запит.
Why. A shared staging has three illnesses: other teams’ deploys break your tests, the data is always dirty, and third-party services (a bank, an SMS provider) are unavailable or cost money. A component test where my service is real, the database comes from Testcontainers and external APIs are virtualised is stable and fast. Besides, a virtual service can do what the real one cannot: return a 503, a 5-second delay or drop the connection on request.
Як тестувати. Я будую піраміду середовищ. Юніт-рівень — моки в пам’яті. Компонентний — сервіс плюс Testcontainers плюс WireMock-стаби, тут ганяю негативні сценарії й таймаути. Контрактний — Pact, щоб стаби не розійшлися з реальністю. Інтеграційний — ефемерне середовище на кілька сервісів. І лише невелика група E2E-перевірок на staging. Стаби генерую з контрактів або з записаного трафіку, а не пишу руками з пам’яті.
How to test. I build a pyramid of environments. Unit level: in-memory mocks. Component level: the service plus Testcontainers plus WireMock stubs, where I run negative scenarios and timeouts. Contract level: Pact, so the stubs do not drift from reality. Integration level: an ephemeral environment with several services. And only a small group of E2E checks on staging. I generate stubs from contracts or recorded traffic rather than writing them by hand from memory.
Підводні камені. Головний ризик віртуалізації — розбіжність: стаб відповідає як рік тому, тести зелені, а продакшн падає. Саме тому стаби треба прив’язувати до контрактів. H2 замість PostgreSQL чи вбудована Kafka поводяться інакше, ніж справжні, і ховають баги з блокуваннями й типами даних. Ефемерні середовища коштують грошей і часу старту, тож їм потрібен автоматичний TTL.
Pitfalls. The main risk of virtualisation is drift: the stub answers the way the provider did a year ago, tests are green, production fails. That is exactly why stubs must be tied to contracts. H2 instead of PostgreSQL or an embedded Kafka behave differently from the real thing and hide bugs with locking and data types. Ephemeral environments cost money and start-up time, so they need an automatic TTL.
Уточнення. «Коли все ж потрібен staging?» — для перевірки конфігурації, мережевих політик, секретів, міграцій на великих даних і справжніх інтеграцій з партнерами в їхніх sandbox. Тобто для того, що неможливо відтворити в контейнері.
Follow-up. “When do you still need staging?” For checking configuration, network policies, secrets, migrations on large data and real integrations with partners in their sandboxes. In other words, for whatever cannot be reproduced in a container.
@SpringBootTest
@Testcontainers
class PaymentComponentTest {
@Container
static PostgreSQLContainer<?> db =
new PostgreSQLContainer<>("postgres:16-alpine");
@Container // віртуальний банк замість справжнього
static GenericContainer<?> bank =
new GenericContainer<>("wiremock/wiremock:3.9.1")
.withExposedPorts(8080);
@DynamicPropertySource
static void props(DynamicPropertyRegistry r) {
r.add("spring.datasource.url", db::getJdbcUrl);
r.add("spring.datasource.username", db::getUsername);
r.add("spring.datasource.password", db::getPassword);
r.add("bank.base-url", () ->
"http://" + bank.getHost() + ":" + bank.getMappedPort(8080));
}
// тести: стаб банку відповідає 503 -> платіж у статусі RETRY
}
@SpringBootTest
@Testcontainers
class PaymentComponentTest {
@Container
static PostgreSQLContainer<?> db =
new PostgreSQLContainer<>("postgres:16-alpine");
@Container // a virtual bank instead of the real one
static GenericContainer<?> bank =
new GenericContainer<>("wiremock/wiremock:3.9.1")
.withExposedPorts(8080);
@DynamicPropertySource
static void props(DynamicPropertyRegistry r) {
r.add("spring.datasource.url", db::getJdbcUrl);
r.add("spring.datasource.username", db::getUsername);
r.add("spring.datasource.password", db::getPassword);
r.add("bank.base-url", () ->
"http://" + bank.getHost() + ":" + bank.getMappedPort(8080));
}
// tests: bank stub answers 503 -> payment ends in RETRY status
}
@Testcontainers і @Container керують життям контейнерів: статичні поля стартують один раз на клас. PostgreSQLContainer дає справжню базу потрібної версії, GenericContainer з образом WireMock — HTTP-сервер для стабів. @DynamicPropertySource підставляє в Spring випадкові порти, які видав Docker, тож тест не залежить від зайнятих портів на машині.@Testcontainers and @Container manage the containers’ lifecycle: static fields start once per class. PostgreSQLContainer gives a real database of the chosen version, and GenericContainer with the WireMock image gives an HTTP server for stubs. @DynamicPropertySource feeds Spring the random ports Docker assigned, so the test does not depend on which ports are busy on the machine.Що це. Chaos engineering — дисципліна експериментів, які навмисно вносять контрольований збій, щоб перевірити гіпотезу про стійкість системи. Це не «вимкнемо щось у production і подивимось». Експеримент має чотири частини: steady-state hypothesis (виміряний нормальний стан, наприклад частка успішних замовлень ≥ 99.5% і p95 < 400 мс), подію збою, обмежений радіус ураження (blast radius) і умови негайної зупинки.
What it is. Chaos engineering is a discipline of experiments that deliberately inject a controlled failure to test a hypothesis about the system’s resilience. It is not “let’s switch something off in production and see”. An experiment has four parts: a steady-state hypothesis (a measured normal state, for example successful orders ≥ 99.5% and p95 < 400 ms), a failure event, a limited blast radius, and conditions for stopping immediately.
Навіщо. Таймаути, ретраї, circuit breaker і failover часто існують лише в конфігурації й ніколи не спрацьовували по-справжньому. Експеримент перетворює припущення «якщо Redis впаде, ми віддамо дані з бази» на перевірений факт або на знайдений баг. Для QA це продовження негативного тестування на рівень системи: ми тестуємо не функцію, а поведінку під збоєм.
Why. Timeouts, retries, circuit breakers and failover often exist only in configuration and have never really fired. An experiment turns the assumption “if Redis dies we serve data from the database” into a verified fact or a found bug. For QA it extends negative testing to the system level: we are testing not a function but behaviour under failure.
Як тестувати. Порядок важливий. Спершу передумови: є метрики steady state, алерти, runbook і кнопка відкату. Потім staging чи окреме тестове середовище під навантаженням, схожим на реальне: вносимо затримку 500 мс у виклик платежів через Toxiproxy або mesh і дивимось, чи тримається гіпотеза. Лише після кількох успішних прогонів — production з мінімальним радіусом: один под, 1% трафіку, робочі години, команда на зв’язку, автоматична зупинка при порушенні steady state.
How to test. The order matters. First the prerequisites: steady-state metrics, alerts, a runbook and a rollback button. Then staging or a dedicated test environment under realistic load: inject 500 ms of latency into the payments call via Toxiproxy or the mesh and see whether the hypothesis holds. Only after several successful runs comes production with the smallest radius: one pod, 1% of traffic, business hours, the team online, and automatic abort when the steady state is violated.
Підводні камені. Запуск у production без спостережуваності — це просто інцидент, який ти спричинив сам. Експеримент без гіпотези не дає висновку: щось упало, але що саме ми дізнались? Широкий радіус ураження на старті перетворює перевірку на аварію. І не варто починати з рідкісних катастроф: найкорисніші перші експерименти — затримка залежності, падіння одного екземпляра і переповнений диск.
Pitfalls. Running in production without observability is just an incident you caused yourself. An experiment without a hypothesis yields no conclusion: something broke, but what did we learn? A wide blast radius at the start turns a check into an outage. And do not start with rare disasters: the most useful first experiments are a slow dependency, one instance dying and a full disk.
Уточнення. Інструменти можуть спитати окремо: Chaos Mesh і LitmusChaos для Kubernetes, AWS Fault Injection Service для хмари, Toxiproxy для мережевих збоїв у тестах, fault injection в Istio. Але інтерв’юера цікавить не інструмент, а дисципліна: гіпотеза, радіус, зупинка.
Follow-up. You may be asked about tools: Chaos Mesh and LitmusChaos for Kubernetes, AWS Fault Injection Service for the cloud, Toxiproxy for network faults in tests, fault injection in Istio. But the interviewer cares less about the tool than the discipline: hypothesis, radius, abort.
experiment: payments-latency-01
hypothesis: # steady state, який має втриматись
- success_ratio(/checkout) >= 0.995
- p95_latency(/checkout) < 400ms
- circuit breaker payments переходить у OPEN за < 30s
method:
fault: latency 500ms +/- 100ms на виклики payment-service
tool: toxiproxy (staging) / istio fault (prod)
duration: 10m
blast_radius:
stage_1: staging, синтетичне навантаження 200 rps
stage_2: prod, 1 под order-service, ~1% трафіку
abort_if: # автоматична зупинка
- success_ratio < 0.99 протягом 1m
- будь-які подвійні списання в reconciliation
rollback: toxiproxy-cli toxic remove / kubectl apply -f normal.yaml
owner: qa-platform, on-call: @payments
experiment: payments-latency-01
hypothesis: # the steady state that must hold
- success_ratio(/checkout) >= 0.995
- p95_latency(/checkout) < 400ms
- payments circuit breaker goes OPEN in < 30s
method:
fault: latency 500ms +/- 100ms on payment-service calls
tool: toxiproxy (staging) / istio fault (prod)
duration: 10m
blast_radius:
stage_1: staging, synthetic load 200 rps
stage_2: prod, 1 order-service pod, ~1% of traffic
abort_if: # automatic stop
- success_ratio < 0.99 for 1m
- any double charge in reconciliation
rollback: toxiproxy-cli toxic remove / kubectl apply -f normal.yaml
owner: qa-platform, on-call: @payments
hypothesis фіксує вимірювані умови нормальної роботи, а не відчуття «все ок». method описує, який саме збій, чим і на скільки. blast_radius має два етапи, і другий можливий лише після успіху першого. abort_if — пороги автоматичної зупинки, а rollback — готова команда повернення, написана до старту, а не під час паніки.hypothesis captures measurable conditions of normal operation, not a feeling that “all is fine”. method states which fault, with what tool and for how long. blast_radius has two stages, and the second is allowed only after the first succeeds. abort_if holds the automatic stop thresholds, and rollback is a ready command written before the start, not during panic.Що це. У моноліті більшість багів живе в коді, а в мікросервісах велика частина переїжджає в шви: мережеві виклики, формати повідомлень, розбіжні версії, часткові збої й узгодженість даних між базами різних сервісів. Тому стратегія будується навколо цих швів. Я розкладаю її на рівні: юніт, компонентний (сервіс в ізоляції), контрактний, інтеграційний на кілька сервісів, невелика кількість E2E і перевірки в production.
What it is. In a monolith most bugs live in the code, while in microservices a large share moves into the seams: network calls, message formats, mismatched versions, partial failures and data consistency across different services’ databases. So the strategy is built around these seams. I split it into levels: unit, component (the service in isolation), contract, integration across a few services, a small number of E2E tests, and checks in production.
Навіщо. Якщо тестувати мікросервіси як моноліт — сотнями E2E на спільному стенді — набір стає повільним, нестабільним і не показує, хто винен. До того ж незалежний деплой, заради якого все затівалось, зникає, бо кожен реліз чекає загальної регресії. Правильний розподіл дозволяє кожній команді доводити сумісність своїм CI і випускатись окремо.
Why. If you test microservices like a monolith — hundreds of E2E tests on a shared environment — the suite becomes slow and flaky and does not show who is at fault. On top of that, the independent deployment that was the whole point disappears, because every release waits for a common regression. The right split lets every team prove compatibility in its own CI and ship separately.
Як тестувати. Специфічні для мікросервісів перевірки: контракти між кожною парою споживач-постачальник; сценарії відмов — таймаут, 503, повільна відповідь, падіння брокера; ідемпотентність при ретраях і повторній доставці; eventual consistency — дані з’являються в іншому сервісі за очікуваний час; сумісність версій під час rolling deploy, коли стара й нова версії працюють одночасно; спостережуваність — trace id наскрізь і корисні логи. E2E лишаю для 5–10 критичних бізнес-шляхів.
How to test. Checks specific to microservices: contracts for every consumer-provider pair; failure scenarios — timeout, 503, slow response, broker down; idempotency under retries and redelivery; eventual consistency — data shows up in another service within the expected time; version compatibility during a rolling deploy, when old and new versions run side by side; observability — an end-to-end trace id and useful logs. I keep E2E for 5–10 critical business journeys.
Підводні камені. Моки без контрактів дають зелений CI і червоний production. Спільні тестові дані між командами породжують конфлікти, тому кожен тест створює свої дані з унікальним префіксом. Легко забути асинхронні шляхи: API повернуло 202, а подія так і не оброблена. І E2E, які перевіряють те, що вже покрито контрактами, лише множать нестабільність.
Pitfalls. Mocks without contracts give a green CI and a red production. Test data shared between teams causes clashes, so every test creates its own data with a unique prefix. It is easy to forget asynchronous paths: the API returned 202, yet the event was never processed. And E2E tests that re-check what contracts already cover only multiply flakiness.
Уточнення. Якщо спитають, хто пише ці тести, я відповідаю: команда сервісу відповідає за юніт, компонентні й контрактні, а QA або платформна команда — за рамку, інструменти, E2E критичних шляхів і експерименти стійкості. Тестувальник тут швидше архітектор якості, ніж виконавець регресії.
Follow-up. If asked who writes these tests, I say: the service team owns unit, component and contract tests, while QA or a platform team owns the framework, tooling, critical-path E2E and resilience experiments. The tester here is more a quality architect than someone running regression.
Рівень | Що ловить | Середовище | Хто
---------------+----------------------------------+----------------------+---------
Юніт | логіка, валідація | пам’ять, моки | команда
Компонентний | API сервісу, БД, таймаути, 503 | Testcontainers + | команда
| залежностей, ідемпотентність | WireMock |
Контрактний | розбіжність форматів між | Pact + Broker, | обидві
| споживачем і постачальником | can-i-deploy | команди
Інтеграційний | події в черзі, eventual | ефемерне середовище | команди
| consistency, saga-компенсації | на PR | + QA
E2E | 5-10 критичних бізнес-шляхів | staging | QA
Стійкість | каскадні збої, failover | staging -> prod 1% | QA + SRE
Production | синтетика, SLO, burn rate | prod | SRE + QA
Level | What it catches | Environment | Owner
---------------+----------------------------------+----------------------+---------
Unit | logic, validation | memory, mocks | team
Component | service API, DB, timeouts, 503s | Testcontainers + | team
| from deps, idempotency | WireMock |
Contract | format drift between consumer | Pact + Broker, | both
| and provider | can-i-deploy | teams
Integration | queued events, eventual | ephemeral env | teams
| consistency, saga compensations | per PR | + QA
E2E | 5-10 critical business journeys | staging | QA
Resilience | cascading failures, failover | staging -> prod 1% | QA + SRE
Production | synthetics, SLO, burn rate | prod | SRE + QA
Контекст і ризики. ETA — прогноз часу прибуття водія до пасажира і поїздки до місця призначення. Його рахує сервіс, який бере координати водіїв із потоку GPS, дорожній граф і дані про затори від картографічного провайдера, а часто ще й ML-модель корекції. Ризики: застарілі або «стрибаючі» координати, падіння провайдера карт, піки навантаження у п’ятницю ввечері, неправильні часові зони і систематична похибка, яка тихо з’їдає довіру і гроші, бо на ETA спираються ціна й розподіл замовлень.
Context and risks. ETA is the predicted time for a driver to reach the rider and for the trip to reach its destination. It is computed by a service that takes driver positions from a GPS stream, a road graph and traffic data from a map provider, and often an ML correction model. Risks: stale or “jumping” coordinates, the map provider going down, Friday-evening peaks, wrong time zones, and a systematic error that quietly eats trust and money, because pricing and order dispatch rely on the ETA.
Стратегія по рівнях. Юніт-тести — для геоматематики: відстань за формулою гаверсинуса, прив’язка точки до дороги, округлення. Компонентні — сервіс ETA зі стабом картографічного API: відповіді з затором, помилка 503, таймаут. Контрактні — між ETA і сервісами ціноутворення та диспетчеризації. Тести даних — на відтворених записах реальних поїздок. Навантажувальні — на пікову частоту оновлень GPS. У production — моніторинг точності як SLI.
Strategy by layer. Unit tests cover geo maths: haversine distance, snapping a point to a road, rounding. Component tests run the ETA service with a stubbed map API: responses with traffic, a 503 error, a timeout. Contract tests sit between ETA and the pricing and dispatch services. Data tests run on replayed recordings of real trips. Load tests target the peak rate of GPS updates. In production, accuracy is monitored as an SLI.
Ключові тест-кейси. Координата водія старша за 30 секунд не використовується або позначається як ненадійна. GPS-стрибок на 2 км за секунду відкидається фільтром. Провайдер карт недоступний — сервіс віддає грубу оцінку за відстанню з позначкою деградації, а не 500. Пасажир і водій у різних часових поясах біля кордону отримують коректний час. Офлайн-оцінка: на наборі з 10 000 записаних поїздок медіана абсолютної похибки не гірша за базову версію, а p90 не росте, інакше реліз моделі блокується.
Key test cases. A driver position older than 30 seconds is not used or is flagged as unreliable. A GPS jump of 2 km in one second is rejected by the filter. When the map provider is down, the service returns a rough distance-based estimate marked as degraded instead of a 500. A rider and driver in different time zones near a border get correct times. Offline evaluation: on a set of 10,000 recorded trips the median absolute error is no worse than the baseline and p90 does not grow, otherwise the model release is blocked.
Підводні камені. ETA неможливо перевірити точним очікуваним значенням — лише допуском і статистикою на наборі. Тестові записи швидко старіють, бо змінюються дороги й трафік, тому набір треба оновлювати. І легко забути зворотний зв’язок: занижене ETA піднімає скасування, тож метрика скасувань теж частина перевірки.
Pitfalls. ETA cannot be checked against an exact expected value, only against a tolerance and statistics over a data set. Test recordings age quickly because roads and traffic change, so the set must be refreshed. And the feedback loop is easy to forget: an underestimated ETA drives up cancellations, so the cancellation metric is part of the check too.
Уточнення. Якщо спитають, як випускати нову модель ETA, я запропоную shadow-режим: нова модель рахує паралельно зі старою, користувач бачить старий результат, а ми порівнюємо обидва з фактичним часом прибуття. Далі — канарка на одне місто.
Follow-up. If asked how to ship a new ETA model, I would propose shadow mode: the new model computes alongside the old one, users see the old result, and we compare both with the actual arrival time. After that, a canary in one city.
Застосунок водія --GPS кожні 4 s--> location-ingest --> Kafka
|
v
Застосунок пасажира --> gateway --> eta-service <-- driver-locations
| | (Redis, TTL 30s)
map-provider-+ +-- eta-model (ML)
(затори, граф)
|
v
pricing, dispatch (споживачі ETA)
[1] GPS: застарілі точки, стрибки, дублікати, порядок подій
[2] map-provider: 503 / таймаут -> деградована оцінка
[3] модель: офлайн-оцінка на 10 000 поїздок, MAE і p90
[4] контракти ETA -> pricing, dispatch
[5] prod SLI: частка поїздок з похибкою < 2 хв
Driver app --GPS every 4 s--> location-ingest --> Kafka
|
v
Rider app --> gateway --> eta-service <-- driver-locations
| | (Redis, TTL 30s)
map-provider-+ +-- eta-model (ML)
(traffic, graph)
|
v
pricing, dispatch (ETA consumers)
[1] GPS: stale points, jumps, duplicates, event order
[2] map-provider: 503 / timeout -> degraded estimate
[3] model: offline eval on 10,000 trips, MAE and p90
[4] contracts ETA -> pricing, dispatch
[5] prod SLI: share of trips with error < 2 min
Ризик | Тест | Рівень
-------------------------+--------------------------------------+-----------
застаріла позиція | точка старша 30 s ігнорується | компонент
GPS-стрибок | 2 км за 1 s відкидається | юніт
збій провайдера карт | 503 -> оцінка з позначкою degraded | компонент
регресія точності | MAE, p90 на записаних поїздках | офлайн
пік у п’ятницю | 50 000 оновлень GPS/s, p95 ETA<200ms | навантаж.
реліз моделі | shadow + канарка на одне місто | production
Risk | Test | Level
-------------------------+--------------------------------------+-----------
stale position | point older than 30 s is ignored | component
GPS jump | 2 km in 1 s is rejected | unit
map provider outage | 503 -> estimate flagged degraded | component
accuracy regression | MAE, p90 on recorded trips | offline
Friday peak | 50,000 GPS updates/s, p95 ETA<200ms | load
model release | shadow + canary in one city | production