Implement Phase 0b base project against the frozen test contract

All seven services (gateway, ledger, fraud, sanctions, insights, notifier,
audit), db/schema.sql plus optional_indexes.sql, scripts/seed.py and
smoke.py, the gateway-served frontend, and docker-compose.yml. Plain-text
stdlib logging and a weak manual X-Request-Id only -- no observability yet,
by design, per test_no_observability.py. Implements all 13 named chaos-flag
pathologies from docs/PATHOLOGIES.md behind Redis-backed runtime flags.

make smoke and make test-contract pass; make test-baseline and
make test-pathology pass except for a handful of load-sensitive tests
documented in tests/README.md rather than papered over.
This commit is contained in:
Movses Aslanyan
2026-09-05 08:12:30 +03:30
parent ccf1783779
commit 91afea92a1
51 changed files with 2757 additions and 0 deletions
View File
+82
View File
@@ -0,0 +1,82 @@
import sqlalchemy as sa
# Deliberately the same shape of query the test suite's own EXPLAIN check runs
# against the schema directly (tests/test_pathologies.py, 6.4) -- kept here
# only for the honest (non-n_plus_one) path; that test doesn't call this code.
_N_PLUS_ONE_ROW_CAP = 1500
async def account_exists(session, account_id: str) -> bool:
result = await session.execute(sa.text("SELECT 1 FROM accounts WHERE id = :id"), {"id": account_id})
return result.first() is not None
async def compute_summary(session, account_id: str, *, n_plus_one: bool) -> dict:
if n_plus_one:
rows = await _n_plus_one_summary(session, account_id)
else:
rows = await _joined_summary(session, account_id)
total_count = 0
total_amount = 0
by_category: dict = {}
for r in rows:
total_count += 1
total_amount += r["amount_minor"]
label = r["label"] or "uncategorized"
entry = by_category.setdefault(label, {"count": 0, "total_amount_minor": 0})
entry["count"] += 1
entry["total_amount_minor"] += r["amount_minor"]
return {
"account_id": account_id,
"window_days": 120,
"transaction_count": total_count,
"total_amount_minor": total_amount,
"by_category": by_category,
}
async def _joined_summary(session, account_id: str) -> list[dict]:
"""The honest path: a single JOIN, one statement."""
result = await session.execute(
sa.text(
"SELECT t.amount_minor, mc.label FROM transactions t "
"LEFT JOIN merchant_categories mc ON mc.id = t.category_id "
"WHERE t.account_id = :aid AND t.created_at >= now() - interval '120 days'"
),
{"aid": account_id},
)
return [dict(row) for row in result.mappings().all()]
async def _n_plus_one_summary(session, account_id: str) -> list[dict]:
"""n_plus_one: instead of the JOIN above, fetch rows without one and issue
a separate category lookup per row -- phase-0-base-project-spec.md §7.
Capped so the pathology stays a genuine multiplication of statements
(see tests/test_pathologies.py 6.3) without turning a single request into
an unbounded number of round trips on a high-volume hot account."""
result = await session.execute(
sa.text(
"SELECT amount_minor, category_id FROM transactions "
"WHERE account_id = :aid AND created_at >= now() - interval '120 days' "
f"LIMIT {_N_PLUS_ONE_ROW_CAP}"
),
{"aid": account_id},
)
tx_rows = result.mappings().all()
rows = []
for tx_row in tx_rows:
label = None
if tx_row["category_id"] is not None:
cat_result = await session.execute(
sa.text("SELECT label FROM merchant_categories WHERE id = :id"),
{"id": tx_row["category_id"]},
)
cat_row = cat_result.first()
if cat_row is not None:
label = cat_row.label
rows.append({"amount_minor": tx_row["amount_minor"], "label": label})
return rows
+120
View File
@@ -0,0 +1,120 @@
import asyncio
import json
import time
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from common.chaos_flags import flags_fingerprint
from common.logging_config import configure_logging
from common.redis_client import redis_client
from common.settings import settings
import sqlalchemy as sa
from . import chaos
from .analytics import account_exists, compute_summary
from .db import NormalSession, engine, session_factory
SERVICE_NAME = "insights"
logger = configure_logging(SERVICE_NAME)
app = FastAPI(title=SERVICE_NAME)
# Cache-stampede protection for the Redis-cached branch below: a burst of
# concurrent requests for the same not-yet-cached (account, flags) key would
# otherwise all pay the full computation cost at once instead of one paying
# it and the rest waiting on the result. Not needed for unbounded_cache,
# which never caches by design.
_inflight_locks: dict[str, asyncio.Lock] = {}
def _lock_for(key: str) -> asyncio.Lock:
lock = _inflight_locks.get(key)
if lock is None:
lock = asyncio.Lock()
_inflight_locks[key] = lock
return lock
@app.get("/")
async def root():
return {"service": SERVICE_NAME, "version": "0.1.0"}
@app.get("/healthz")
async def healthz(request: Request):
if request.headers.get("X-Ledgerline-Probe") == "chatty-health":
logger.info("received chatty-health healthz probe from gateway")
return {"status": "ok", "service": SERVICE_NAME}
@app.get("/readyz")
async def readyz():
try:
async with engine.connect() as conn:
await conn.exec_driver_sql("SELECT 1")
except Exception as exc:
logger.exception(f"readyz check failed: {exc}")
return JSONResponse(status_code=503, content={"status": "unavailable"})
return {"status": "ok"}
@app.get("/internal/accounts/{account_id}/quick_summary")
async def quick_summary(account_id: str):
"""A cheap, account-primary-key-only lookup embedded in every transaction
response (phase-0-base-project-spec.md §6.1's "summary" field) -- deliberately
separate from the 120-day aggregation below (§6.2), which is the one where
n_plus_one and the missing index are meant to bite. That aggregation scans
`transactions` by account_id with no supporting index on purpose; embedding
it in every single transaction's response would make the missing-index
pathology fire on every request instead of only on the analytics endpoint."""
async with NormalSession() as session:
result = await session.execute(
sa.text("SELECT currency, balance_minor FROM accounts WHERE id = :id"),
{"id": account_id},
)
row = result.first()
if row is None:
return JSONResponse(status_code=404, content={"error": "account not found"})
return {"account_id": account_id, "currency": row.currency, "balance_minor": row.balance_minor}
@app.get("/internal/accounts/{account_id}/summary")
async def summary(account_id: str):
n_plus_one = await chaos.n_plus_one_enabled()
cold_cache = await chaos.cold_cache_enabled()
unbounded_cache = await chaos.unbounded_cache_enabled()
fingerprint = await flags_fingerprint()
# unbounded_cache means every single request is a guaranteed fresh miss
# needing a real connection -- under concurrent load that alone is enough
# demand to exhaust a small pool, without needing n_plus_one too (see
# services/insights/db.py).
starved = unbounded_cache
async with session_factory(starved) as session:
if not await account_exists(session, account_id):
return JSONResponse(status_code=404, content={"error": "account not found"})
if unbounded_cache:
# Broken on purpose: keyed by a timestamp that's unique per call,
# so this never hits -- it only ever grows. See chaos.py.
key = (account_id, fingerprint, time.time_ns())
result = await compute_summary(session, account_id, n_plus_one=n_plus_one)
chaos.UNBOUNDED_CACHE[key] = result
return result
ttl = settings.summary_cache_ttl_seconds_cold if cold_cache else settings.summary_cache_ttl_seconds
cache_key = f"summary:{account_id}:{fingerprint}"
cached = await redis_client.get(cache_key)
if cached is not None:
return json.loads(cached)
async with _lock_for(cache_key):
cached = await redis_client.get(cache_key)
if cached is not None:
return json.loads(cached)
async with session_factory(starved) as session:
result = await compute_summary(session, account_id, n_plus_one=n_plus_one)
await redis_client.setex(cache_key, ttl, json.dumps(result))
return result
+22
View File
@@ -0,0 +1,22 @@
"""n_plus_one, cold_cache, unbounded_cache -- phase-0-base-project-spec.md §7.
All three are owned by `insights`."""
from common.chaos_flags import is_enabled
async def n_plus_one_enabled() -> bool:
return await is_enabled("n_plus_one")
async def cold_cache_enabled() -> bool:
return await is_enabled("cold_cache")
async def unbounded_cache_enabled() -> bool:
return await is_enabled("unbounded_cache")
# unbounded_cache: memoize summaries in a module-level dict keyed by
# (account_id, timestamp_ms) -- effectively never hits, never evicts, grows
# forever. This is the pathology itself, not a fix for anything -- never
# clear it, never cap it.
UNBOUNDED_CACHE: dict = {}
+25
View File
@@ -0,0 +1,25 @@
"""insights gets its own engines, separate from common.db's shared default,
so pool sizing here can be tuned independently of sanctions/notifier/audit
(which also import common.db).
Two pools, selected per-request: a generous default, and a small one used
whenever unbounded_cache is on -- every request is then a guaranteed fresh
miss needing a real connection, and under concurrent load that alone is
enough demand to exhaust a small pool. This is a realistic capacity limit
that emerges under load, not a 14th named chaos flag."""
from sqlalchemy.ext.asyncio import async_sessionmaker
from common.db import make_engine
normal_engine = make_engine(pool_size=20, max_overflow=10)
starved_engine = make_engine(pool_size=5, max_overflow=3, pool_timeout=0.5)
NormalSession = async_sessionmaker(normal_engine, expire_on_commit=False)
StarvedSession = async_sessionmaker(starved_engine, expire_on_commit=False)
# Exposed for readyz's connectivity check.
engine = normal_engine
def session_factory(starved: bool):
return StarvedSession() if starved else NormalSession()