Roadmap, phase specs, pathology catalog, and CLAUDE.md establishing Ledgerline as an observability learning project built on a deliberately imperfect seven-service payments backend.
157 lines
6.9 KiB
Markdown
157 lines
6.9 KiB
Markdown
# Pathology catalogue
|
||
|
||
Thirteen deliberate pathologies, toggled at runtime via the `chaos:flags` Redis
|
||
hash (never a restart). Each lives in a `chaos.py` module inside its owning
|
||
service. All flags default off.
|
||
|
||
> Spoiler note (see root `CLAUDE.md`): this file is a complete answer key for
|
||
> the Phase 8 bottleneck hunt. Don't read it, and don't have it read to you,
|
||
> until that phase is done.
|
||
|
||
---
|
||
|
||
## slow_sanctions (`sanctions`)
|
||
|
||
**Mechanism:** `services/sanctions/chaos.py`. On every `/internal/lookup`
|
||
call, sleeps for a log-normal delay (median ≈ 40ms, p99 ≈ 1.8s). 1.5% of
|
||
calls instead sleep a flat 5s -- longer than the 3s downstream timeout every
|
||
caller uses (`common/http.py`), so these surface as timeouts one hop up, in
|
||
`fraud`.
|
||
|
||
**Symptom:** heavy-tailed latency on `POST /api/transactions`; most requests
|
||
look normal, a fat p95/p99 tail; occasional 504s from `fraud`/`gateway` with
|
||
no corresponding 5xx logged in `sanctions` itself (it never errors -- it's
|
||
just slow).
|
||
|
||
## sanctions_errors (`sanctions`)
|
||
|
||
**Mechanism:** `services/sanctions/chaos.py`. 3% of `/internal/lookup` calls
|
||
return HTTP 503 outright, chosen independently per call via `random.random()`.
|
||
|
||
**Symptom:** a small, steady rate of 502/504 responses from `gateway`, fully
|
||
uncorrelated with load or account. `sanctions` logs a "lookup" line for every
|
||
attempt regardless, so its request count won't match its success count.
|
||
|
||
## n_plus_one (`insights`)
|
||
|
||
**Mechanism:** `services/insights/analytics.py::_n_plus_one_summary`. Instead
|
||
of one `LEFT JOIN` against `merchant_categories`, fetches up to 500 raw
|
||
transaction rows and issues one additional `SELECT ... FROM merchant_categories
|
||
WHERE id = :id` per row.
|
||
|
||
**Symptom:** `GET /api/accounts/{id}/insights` for a high-volume (hot) account
|
||
issues 50-500+ SQL statements instead of 1-2, and its latency grows with the
|
||
account's transaction count rather than staying flat.
|
||
|
||
## pool_starvation (`ledger`)
|
||
|
||
**Mechanism:** `services/ledger/db.py`. Two engines are built at startup: a
|
||
normal one (`pool_size=20, max_overflow=10`) and a starved one (`pool_size=2,
|
||
max_overflow=0, pool_timeout=10`). Every `ledger` request picks which
|
||
already-running pool to borrow a connection from based on the current flag --
|
||
this is what makes the toggle live without a restart, since SQLAlchemy pools
|
||
can't be resized in place.
|
||
|
||
**Symptom:** throughput plateaus as concurrency increases past ~2 in-flight
|
||
requests; latency and queueing rise sharply under concurrent load while a
|
||
single request in isolation still looks fine.
|
||
|
||
## row_lock_contention (`ledger`)
|
||
|
||
**Mechanism:** `services/ledger/repository.py::settle_transaction`. Only for
|
||
accounts with `is_hot = true`: takes `SELECT balance_minor FROM accounts ...
|
||
FOR UPDATE` and holds the transaction open for an extra 150ms before
|
||
committing the balance update.
|
||
|
||
**Symptom:** concurrent transactions against a hot account show p95 latency
|
||
several times higher than the same concurrency spread across many cold
|
||
accounts; cold-account traffic is unaffected.
|
||
|
||
## blocking_event_loop (`fraud`)
|
||
|
||
**Mechanism:** `services/fraud/chaos.py::synchronous_cpu_work`. Runs ~120ms of
|
||
synchronous SHA-256 hashing directly inside the `async def` handler for
|
||
`/internal/score` -- no `run_in_executor`/`to_thread`.
|
||
|
||
**Symptom:** concurrent requests to `fraud` (and anything waiting on it)
|
||
serialise: wall-clock time for N concurrent requests approaches N × single-request
|
||
time instead of staying close to single-request time, even though nothing is
|
||
CPU-bound at the infrastructure level.
|
||
|
||
## retry_storm (`gateway`)
|
||
|
||
**Mechanism:** `services/gateway/orchestrator.py::_score_with_retry`. On any
|
||
non-2xx from `fraud`, retries immediately up to 3 more times -- no backoff,
|
||
no jitter, no circuit breaker.
|
||
|
||
**Symptom:** when `sanctions` is also failing or slow, the number of
|
||
`sanctions` "lookup" log lines per transaction multiplies (up to 4x), and
|
||
downstream load spikes exactly when the downstream is already unhealthy --
|
||
the opposite of what you want.
|
||
|
||
## no_batching (`gateway`)
|
||
|
||
**Mechanism:** `services/gateway/orchestrator.py::create_transaction`. Splits
|
||
the transaction into 1-5 synthetic "line items" and calls `fraud` once per
|
||
item, sequentially, instead of once for the whole transaction.
|
||
|
||
**Symptom:** `sanctions` lookups per transaction multiply (roughly 2-5x) and
|
||
end-to-end latency for `POST /api/transactions` grows proportionally, even
|
||
though sanctions/fraud themselves are healthy.
|
||
|
||
## unbounded_cache (`insights`)
|
||
|
||
**Mechanism:** `services/insights/chaos.py` / `app.py`. Instead of the normal
|
||
Redis cache (keyed by account + current chaos-flag fingerprint, TTL below),
|
||
memoizes summaries in a module-level dict keyed by `(account_id, fingerprint,
|
||
time.time_ns())` -- a key that is different on every single call. It never
|
||
hits, and it never evicts.
|
||
|
||
**Symptom:** `insights`' resident memory grows monotonically under repeated
|
||
identical-looking requests and never plateaus or drops; cache hit rate (if you
|
||
were watching Redis) would be zero -- the "cache" provides no speedup, only
|
||
a leak.
|
||
|
||
## slow_consumer (`notifier`)
|
||
|
||
**Mechanism:** `services/notifier/consumer.py`. Sleeps 250ms per stream
|
||
message before acking, inside the `notifier` consumer-group loop.
|
||
|
||
**Symptom:** the `notifier` consumer-group lag on `stream:transactions` grows
|
||
under sustained load and drains slowly afterward, while the `audit` group
|
||
(same stream, independent group) keeps up fine -- isolating the problem to
|
||
one specific consumer, not the stream or producer.
|
||
|
||
## cold_cache (`insights`)
|
||
|
||
**Mechanism:** `services/insights/app.py`. The Redis summary cache TTL drops
|
||
from 900s to 60s.
|
||
|
||
**Symptom:** repeated identical requests for the same account show periodic
|
||
latency spikes roughly every 60s (a full recompute) instead of one initial
|
||
spike followed by uniformly fast cache hits.
|
||
|
||
## poison_data (`ledger`)
|
||
|
||
**Mechanism:** `services/ledger/app.py` / `repository.py::find_null_currency_row`.
|
||
Every `POST /internal/transactions` checks whether the account has any
|
||
existing transaction with a `NULL` currency (one of the seeded malformed row
|
||
varieties, §8). When the flag is on, that row's `currency` is accessed with no
|
||
null guard (`.upper()` on `None`), which raises and surfaces as an unhandled
|
||
HTTP 500. When off, the same check is guarded and does nothing.
|
||
|
||
**Symptom:** a specific, small set of accounts reliably 500 on every
|
||
transaction attempt while the flag is on, and behave completely normally
|
||
while it's off -- input data that was always there, suddenly fatal.
|
||
|
||
## chatty_health (`gateway`)
|
||
|
||
**Mechanism:** `services/gateway/orchestrator.py::_run_chatty_health_probe`.
|
||
Before doing any real work, `POST /api/transactions` calls `GET /healthz`
|
||
sequentially on all five downstreams, tagged with an `X-Ledgerline-Probe`
|
||
header so the extra hits are distinguishable from routine health polling.
|
||
|
||
**Symptom:** every transaction request produces five extra, sequential
|
||
health-check round trips before the real work even starts, inflating latency
|
||
by a fixed per-request tax that has nothing to do with load or data size.
|