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:
@@ -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
|
||||
@@ -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
|
||||
@@ -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 = {}
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user