Submit a transaction
+ +Result
+Round-trip: -- ms · request_id: --
(nothing yet)+
Load burst
+ + +| count | success | errors | min | mean | p50 | p95 | max |
|---|---|---|---|---|---|---|---|
| no burst run yet | |||||||
diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b98e55a --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +# Copy to .env if you want to override defaults for a local, non-Compose run. +# Inside Compose, these are set directly in docker-compose.yml per service. + +POSTGRES_DSN=postgresql+asyncpg://ledgerline:ledgerline@localhost:5432/ledgerline +REDIS_URL=redis://localhost:6379/0 + +GATEWAY_URL=http://localhost:8000 +LEDGER_URL=http://localhost:8001 +FRAUD_URL=http://localhost:8002 +SANCTIONS_URL=http://localhost:8003 +INSIGHTS_URL=http://localhost:8004 +NOTIFIER_URL=http://localhost:8005 + +SEED=42 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c6d3959 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +FROM python:3.12-slim + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PIP_NO_CACHE_DIR=1 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY requirements.txt . +RUN pip install -r requirements.txt + +COPY common ./common +COPY services ./services +COPY db ./db +COPY scripts ./scripts + +# CMD differs per service -- see docker-compose.yml. +CMD ["python", "-c", "print('no default command: set one in docker-compose.yml')"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..90d0cfa --- /dev/null +++ b/Makefile @@ -0,0 +1,70 @@ +# Ledgerline -- application targets (phase-0-base-project-spec.md §12) plus +# the Phase 0a test-suite targets. `make up && make seed` are prerequisites +# for every test target below. + +PYTEST ?= pytest +COMPOSE ?= docker compose +SEED_DSN ?= postgresql://ledgerline:ledgerline@postgres:5432/ledgerline +GATEWAY_URL ?= http://localhost:8000 + +.PHONY: up down logs seed reset smoke \ + chaos-clean chaos-light chaos-nightmare chaos-random \ + fix-index drop-index \ + test test-contract test-baseline test-pathology test-slow test-all + +up: + $(COMPOSE) up -d --build + +down: + $(COMPOSE) down + +logs: + $(COMPOSE) logs -f + +seed: + cat db/schema.sql | $(COMPOSE) exec -T postgres psql -U ledgerline -d ledgerline + $(COMPOSE) run --rm -e POSTGRES_DSN=$(SEED_DSN) gateway python scripts/seed.py + +reset: + $(COMPOSE) down -v + $(MAKE) up + $(MAKE) seed + +smoke: + $(COMPOSE) run --rm -e POSTGRES_DSN=$(SEED_DSN) -e GATEWAY_URL=http://gateway:8000 gateway python scripts/smoke.py + +chaos-clean: + curl -sf -X POST $(GATEWAY_URL)/api/admin/chaos/profile -H 'Content-Type: application/json' -d '{"profile":"clean"}' + +chaos-light: + curl -sf -X POST $(GATEWAY_URL)/api/admin/chaos/profile -H 'Content-Type: application/json' -d '{"profile":"light"}' + +chaos-nightmare: + curl -sf -X POST $(GATEWAY_URL)/api/admin/chaos/profile -H 'Content-Type: application/json' -d '{"profile":"nightmare"}' + +chaos-random: + curl -sf -X POST $(GATEWAY_URL)/api/admin/chaos/profile -H 'Content-Type: application/json' -d '{"profile":"random"}' + +fix-index: + cat db/optional_indexes.sql | $(COMPOSE) exec -T postgres psql -U ledgerline -d ledgerline + +drop-index: + cat db/drop_indexes.sql | $(COMPOSE) exec -T postgres psql -U ledgerline -d ledgerline + +test: + $(PYTEST) -m "not slow" + +test-contract: + $(PYTEST) -m "contract or seed or guard" + +test-baseline: + $(PYTEST) -m baseline + +test-pathology: + $(PYTEST) -m "pathology and not slow" + +test-slow: + $(PYTEST) -m slow + +test-all: + $(PYTEST) diff --git a/common/__init__.py b/common/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/common/chaos_flags.py b/common/chaos_flags.py new file mode 100644 index 0000000..a0e5db3 --- /dev/null +++ b/common/chaos_flags.py @@ -0,0 +1,75 @@ +"""Shared low-level access to the `chaos:flags` Redis hash. + +This is infrastructure (a Redis hash reader/writer), not pathology behaviour -- +the mechanism each flag triggers lives in that service's own chaos.py, per +phase-0-base-project-spec.md §7 ("each pathology lives in a clearly named +chaos.py module inside its owning service"). Every service re-reads the hash +on every request; nothing here is cached in-process. +""" +import random +import time + +from .redis_client import redis_client + +FLAGS_KEY = "chaos:flags" + +ALL_FLAGS = [ + "slow_sanctions", + "sanctions_errors", + "n_plus_one", + "pool_starvation", + "row_lock_contention", + "blocking_event_loop", + "retry_storm", + "no_batching", + "unbounded_cache", + "slow_consumer", + "cold_cache", + "poison_data", + "chatty_health", +] + +NAMED_PROFILES = { + "clean": set(), + "light": {"slow_sanctions", "cold_cache"}, + "nightmare": set(ALL_FLAGS), +} + + +async def get_flags() -> dict: + raw = await redis_client.hgetall(FLAGS_KEY) + return {flag: raw.get(flag) == "1" for flag in ALL_FLAGS} + + +async def is_enabled(flag: str) -> bool: + return await redis_client.hget(FLAGS_KEY, flag) == "1" + + +async def set_flag(flag: str, enabled: bool) -> None: + if flag not in ALL_FLAGS: + raise ValueError(f"unknown flag {flag!r}") + await redis_client.hset(FLAGS_KEY, flag, "1" if enabled else "0") + + +async def set_profile(profile: str) -> dict: + if profile == "random": + # Seed derived from time.time(), never logged -- the active set lives + # only in the `chaos:flags` hash, per §7's "random" profile note. + rng = random.Random(time.time()) + chosen = set(rng.sample(ALL_FLAGS, k=rng.randint(3, 6))) + elif profile in NAMED_PROFILES: + chosen = NAMED_PROFILES[profile] + else: + raise ValueError(f"unknown profile {profile!r}") + + mapping = {flag: ("1" if flag in chosen else "0") for flag in ALL_FLAGS} + await redis_client.hset(FLAGS_KEY, mapping=mapping) + return await get_flags() + + +async def flags_fingerprint() -> str: + """A short, stable string identifying the current flag combination. Used + to key the insights summary cache so a live flag change (G5) is never + masked by a stale cache entry written under a different chaos state.""" + flags = await get_flags() + return "".join("1" if flags[f] else "0" for f in ALL_FLAGS) diff --git a/common/db.py b/common/db.py new file mode 100644 index 0000000..2864869 --- /dev/null +++ b/common/db.py @@ -0,0 +1,15 @@ +from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + +from .settings import settings + + +def make_engine(**overrides): + """A fresh async engine. Services that need more than one pool profile + (see ledger/chaos.py for pool_starvation) call this more than once.""" + kwargs = dict(pool_pre_ping=True) + kwargs.update(overrides) + return create_async_engine(settings.postgres_dsn, **kwargs) + + +engine = make_engine(pool_size=20, max_overflow=10) +Session = async_sessionmaker(engine, expire_on_commit=False) diff --git a/common/http.py b/common/http.py new file mode 100644 index 0000000..f708651 --- /dev/null +++ b/common/http.py @@ -0,0 +1,13 @@ +import httpx + +from .settings import settings + + +def downstream_client(request_id: str) -> httpx.AsyncClient: + """An httpx.AsyncClient preconfigured with the 3s downstream timeout and + the X-Request-Id header forwarded, per phase-0-base-project-spec.md §9 -- + this header is the only correlation mechanism that exists in Phase 0.""" + return httpx.AsyncClient( + timeout=settings.downstream_timeout_seconds, + headers={"X-Request-Id": request_id}, + ) diff --git a/common/logging_config.py b/common/logging_config.py new file mode 100644 index 0000000..abc639c --- /dev/null +++ b/common/logging_config.py @@ -0,0 +1,12 @@ +import logging + + +def configure_logging(service_name: str) -> logging.Logger: + """Plain-text, human-prose logging -- the deliberately weak Phase 0 + baseline. Per phase-0-base-project-spec.md §9: no JSON, no key-value, no + structured fields. Do not "improve" this; later phases replace it.""" + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s %(message)s", + ) + return logging.getLogger(service_name) diff --git a/common/redis_client.py b/common/redis_client.py new file mode 100644 index 0000000..937c45f --- /dev/null +++ b/common/redis_client.py @@ -0,0 +1,5 @@ +import redis.asyncio as redis + +from .settings import settings + +redis_client = redis.from_url(settings.redis_url, decode_responses=True) diff --git a/common/settings.py b/common/settings.py new file mode 100644 index 0000000..f594d8e --- /dev/null +++ b/common/settings.py @@ -0,0 +1,27 @@ +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", extra="ignore") + + service_name: str = "unknown" + + postgres_dsn: str = "postgresql+asyncpg://ledgerline:ledgerline@postgres:5432/ledgerline" + redis_url: str = "redis://redis:6379/0" + + gateway_url: str = "http://gateway:8000" + ledger_url: str = "http://ledger:8001" + fraud_url: str = "http://fraud:8002" + sanctions_url: str = "http://sanctions:8003" + insights_url: str = "http://insights:8004" + notifier_url: str = "http://notifier:8005" + + downstream_timeout_seconds: float = 3.0 + + stream_name: str = "stream:transactions" + + summary_cache_ttl_seconds: int = 900 + summary_cache_ttl_seconds_cold: int = 60 + + +settings = Settings() diff --git a/db/drop_indexes.sql b/db/drop_indexes.sql new file mode 100644 index 0000000..a0aeaeb --- /dev/null +++ b/db/drop_indexes.sql @@ -0,0 +1,4 @@ +-- Applied only by `make drop-index`. Reverses db/optional_indexes.sql. + +DROP INDEX IF EXISTS transactions_account_created_idx; +DROP INDEX IF EXISTS transactions_category_idx; diff --git a/db/optional_indexes.sql b/db/optional_indexes.sql new file mode 100644 index 0000000..5019f95 --- /dev/null +++ b/db/optional_indexes.sql @@ -0,0 +1,5 @@ +-- Applied only by `make fix-index` (docs/phase-0-base-project-spec.md §5, §12). +-- These are the indexes deliberately omitted from db/schema.sql. + +CREATE INDEX IF NOT EXISTS transactions_account_created_idx ON transactions (account_id, created_at); +CREATE INDEX IF NOT EXISTS transactions_category_idx ON transactions (category_id); diff --git a/db/schema.sql b/db/schema.sql new file mode 100644 index 0000000..d56b110 --- /dev/null +++ b/db/schema.sql @@ -0,0 +1,80 @@ +-- Ledgerline schema. Idempotent: safe to run against an existing database. +-- Index policy is deliberate -- see docs/phase-0-base-project-spec.md §5. +-- Do NOT add transactions(account_id, created_at) or transactions(category_id) +-- here; those live in db/optional_indexes.sql, applied only by `make fix-index`. + +CREATE EXTENSION IF NOT EXISTS pg_stat_statements; + +CREATE TABLE IF NOT EXISTS accounts ( + id UUID PRIMARY KEY, + holder_name TEXT NOT NULL, + currency CHAR(3) NOT NULL, + balance_minor BIGINT NOT NULL, + is_hot BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS merchant_categories ( + id SMALLINT PRIMARY KEY, + code TEXT NOT NULL UNIQUE, + label TEXT NOT NULL, + risk_weight NUMERIC(3,2) NOT NULL +); + +CREATE TABLE IF NOT EXISTS transactions ( + id UUID PRIMARY KEY, + account_id UUID NOT NULL, + kind TEXT NOT NULL, + amount_minor BIGINT NOT NULL, + currency CHAR(3), + merchant_name TEXT, + category_id SMALLINT, + status TEXT NOT NULL, + risk_score NUMERIC(4,3), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Parallel workers otherwise let a plain sequential scan of this table +-- compete with an index scan on raw CPU parallelism for higher-selectivity +-- accounts (e.g. the 5 hot ones, each ~6-8% of all rows) -- masking exactly +-- the missing-index pathology this table's index policy (README §5) exists +-- to demonstrate. Scoped to this one table, not a database-wide setting. +ALTER TABLE transactions SET (parallel_workers = 0); + +CREATE TABLE IF NOT EXISTS sanctions_entries ( + id SERIAL PRIMARY KEY, + normalized_name TEXT NOT NULL, + country CHAR(2), + list_source TEXT, + added_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS notifications ( + id UUID PRIMARY KEY, + transaction_id UUID NOT NULL, + channel TEXT NOT NULL, + status TEXT NOT NULL, + attempts INT NOT NULL DEFAULT 0, + sent_at TIMESTAMPTZ +); + +CREATE TABLE IF NOT EXISTS audit_events ( + id BIGSERIAL PRIMARY KEY, + transaction_id UUID NOT NULL, + event_type TEXT NOT NULL, + payload JSONB, + recorded_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Not part of the base spec's domain model: a single-row table the seed +-- script writes its summary manifest into, so both `scripts/smoke.py` and +-- the test suite can read back what a given seed run actually produced +-- (row counts, hot/sanctioned/poison account ids, the SEED value used) +-- without re-deriving it via ad-hoc queries. +CREATE TABLE IF NOT EXISTS seed_manifest ( + id SMALLINT PRIMARY KEY DEFAULT 1, + data JSONB NOT NULL +); + +CREATE INDEX IF NOT EXISTS accounts_holder_name_idx ON accounts (holder_name); +CREATE INDEX IF NOT EXISTS sanctions_entries_normalized_name_idx ON sanctions_entries (normalized_name); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2ba5ab1 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,205 @@ +x-app-env: &app-env + POSTGRES_DSN: postgresql+asyncpg://ledgerline:ledgerline@postgres:5432/ledgerline + REDIS_URL: redis://redis:6379/0 + GATEWAY_URL: http://gateway:8000 + LEDGER_URL: http://ledger:8001 + FRAUD_URL: http://fraud:8002 + SANCTIONS_URL: http://sanctions:8003 + INSIGHTS_URL: http://insights:8004 + NOTIFIER_URL: http://notifier:8005 + +x-app-build: &app-build + build: + context: . + dockerfile: Dockerfile + +x-app-volumes: &app-volumes + volumes: + - ./common:/app/common + - ./services:/app/services + - ./db:/app/db + - ./scripts:/app/scripts + +x-app-depends: &app-depends + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + +services: + postgres: + image: postgres:16-alpine + # max_connections=300: five separate services each keep their own + # SQLAlchemy pool (several sized up to pool_size=20+max_overflow=10), + # all against this one Postgres instance -- comfortably clears the + # default 100 under concurrent load instead of silently rationing + # connections in a way that looks like an unrelated pathology. + command: + - postgres + - -c + - shared_preload_libraries=pg_stat_statements + - -c + - pg_stat_statements.track=all + - -c + - pg_stat_statements.max=10000 + - -c + - max_connections=300 + environment: + POSTGRES_USER: ledgerline + POSTGRES_PASSWORD: ledgerline + POSTGRES_DB: ledgerline + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ledgerline -d ledgerline"] + interval: 3s + timeout: 3s + retries: 20 + volumes: + - pgdata:/var/lib/postgresql/data + + redis: + image: redis:7-alpine + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 3s + timeout: 3s + retries: 20 + + gateway: + <<: [*app-build, *app-depends] + volumes: + - ./common:/app/common + - ./services:/app/services + - ./db:/app/db + - ./scripts:/app/scripts + environment: + <<: *app-env + SERVICE_NAME: gateway + command: uvicorn services.gateway.app:app --host 0.0.0.0 --port 8000 --reload --no-access-log + ports: + - "8000:8000" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/healthz"] + interval: 5s + timeout: 3s + retries: 10 + start_period: 10s + + ledger: + <<: [*app-build, *app-depends] + volumes: + - ./common:/app/common + - ./services:/app/services + - ./db:/app/db + - ./scripts:/app/scripts + environment: + <<: *app-env + SERVICE_NAME: ledger + command: uvicorn services.ledger.app:app --host 0.0.0.0 --port 8001 --reload --no-access-log + ports: + - "8001:8001" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8001/healthz"] + interval: 5s + timeout: 3s + retries: 10 + start_period: 10s + + fraud: + <<: [*app-build, *app-depends] + volumes: + - ./common:/app/common + - ./services:/app/services + - ./db:/app/db + - ./scripts:/app/scripts + environment: + <<: *app-env + SERVICE_NAME: fraud + command: uvicorn services.fraud.app:app --host 0.0.0.0 --port 8002 --reload --no-access-log + ports: + - "8002:8002" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8002/healthz"] + interval: 5s + timeout: 3s + retries: 10 + start_period: 10s + + sanctions: + <<: [*app-build, *app-depends] + volumes: + - ./common:/app/common + - ./services:/app/services + - ./db:/app/db + - ./scripts:/app/scripts + environment: + <<: *app-env + SERVICE_NAME: sanctions + command: uvicorn services.sanctions.app:app --host 0.0.0.0 --port 8003 --reload --no-access-log + ports: + - "8003:8003" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8003/healthz"] + interval: 5s + timeout: 3s + retries: 10 + start_period: 10s + + insights: + <<: [*app-build, *app-depends] + volumes: + - ./common:/app/common + - ./services:/app/services + - ./db:/app/db + - ./scripts:/app/scripts + environment: + <<: *app-env + SERVICE_NAME: insights + command: uvicorn services.insights.app:app --host 0.0.0.0 --port 8004 --reload --no-access-log + ports: + - "8004:8004" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8004/healthz"] + interval: 5s + timeout: 3s + retries: 10 + start_period: 10s + + notifier: + <<: [*app-build, *app-depends] + volumes: + - ./common:/app/common + - ./services:/app/services + - ./db:/app/db + - ./scripts:/app/scripts + environment: + <<: *app-env + SERVICE_NAME: notifier + command: uvicorn services.notifier.app:app --host 0.0.0.0 --port 8005 --reload --no-access-log + ports: + - "8005:8005" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8005/healthz"] + interval: 5s + timeout: 3s + retries: 10 + start_period: 10s + + audit: + <<: [*app-build, *app-depends] + volumes: + - ./common:/app/common + - ./services:/app/services + - ./db:/app/db + - ./scripts:/app/scripts + environment: + <<: *app-env + SERVICE_NAME: audit + command: python -m services.audit.worker + +volumes: + pgdata: diff --git a/observability/.gitkeep b/observability/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4c12824 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,10 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +httpx==0.28.1 +sqlalchemy[asyncio]==2.0.36 +asyncpg==0.30.0 +psycopg[binary]==3.2.3 +redis==5.2.1 +pydantic==2.10.4 +pydantic-settings==2.7.1 +python-multipart==0.0.20 diff --git a/scripts/seed.py b/scripts/seed.py new file mode 100644 index 0000000..8a9a350 --- /dev/null +++ b/scripts/seed.py @@ -0,0 +1,375 @@ +#!/usr/bin/env python3 +"""Deterministic seed data for Ledgerline -- phase-0-base-project-spec.md §8. + +Idempotent: truncates then inserts. Deterministic given SEED (default 42). +Uses COPY for the transactions table so 250k rows load in well under 60s. +""" +import json +import os +import random +import time +import uuid +from datetime import datetime, timedelta, timezone + +import psycopg + +SEED = int(os.environ.get("SEED", "42")) +DSN = os.environ.get("POSTGRES_DSN", "postgresql://ledgerline:ledgerline@localhost:5432/ledgerline") + +N_ACCOUNTS = 800 +N_HOT = 5 +N_CATEGORIES = 45 +N_TRANSACTIONS = 250_000 +N_SANCTIONS = 12_000 +WINDOW_DAYS = 120 +N_POISON_ACCOUNTS = 25 +N_SANCTIONED_MATCHES = 40 + +MALFORMED_COUNTS = { + "null_currency": 1200, + "negative_deposit": 1000, + "non_ascii_merchant": 1000, + "future_dated": 1000, + "orphan_category": 800, +} +N_MALFORMED = sum(MALFORMED_COUNTS.values()) +N_NORMAL = N_TRANSACTIONS - N_MALFORMED + +FIRST_NAMES = [ + "Amara", "Bilal", "Chiara", "Dimitri", "Elin", "Farrukh", "Greta", "Haruto", + "Ines", "Jonas", "Kalani", "Lucia", "Mateus", "Naledi", "Omar", "Priya", + "Quentin", "Rosalind", "Sami", "Tuuli", "Umberto", "Vera", "Wojciech", "Ximena", + "Yara", "Zoltan", "Anika", "Bram", "Carys", "Dovid", "Esperanza", "Felix", + "Guadalupe", "Henrik", "Ilse", "Jarrah", "Katarzyna", "Leandro", "Mireille", + "Nikolai", +] +LAST_NAMES = [ + "Adeyemi", "Bergstrom", "Castellano", "Dubois", "Eriksson", "Fitzgerald", + "Gallagher", "Haddad", "Ivanov", "Jarvinen", "Kowalski", "Lindqvist", + "Moreau", "Nakamura", "Okafor", "Petrov", "Quiroga", "Rasmussen", "Sokolova", + "Tremblay", "Ueda", "Vasiliev", "Weiss", "Xu", "Yilmaz", "Zimmermann", + "Andersson", "Bianchi", "Costa", "Duarte", +] +MERCHANTS = [ + "Northwind Grocers", "Blue Anchor Cafe", "Solstice Electronics", "Ferro Hardware", + "Willow Creek Pharmacy", "Ember & Oak", "Pinnacle Fitness", "Cobblestone Books", + "Lantern Street Diner", "Cedarline Motors", "Harborview Market", "Quill & Ink Supply", + "Meridian Travel", "Golden Hour Bakery", "Ironwood Furniture", "Tidal Wave Surf Shop", + "Maple & Vine Wines", "Copper Kettle Tea Co", "Driftwood Outfitters", "Verdant Nursery", +] + +CURRENCY_WEIGHTS = [("EUR", 0.70), ("USD", 0.20), ("GBP", 0.10)] + + +def weighted_choice(rng, pairs): + values, weights = zip(*pairs) + return rng.choices(values, weights=weights, k=1)[0] + + +def gen_holder_names(rng, n): + combos = [f"{f} {l}" for f in FIRST_NAMES for l in LAST_NAMES] + rng.shuffle(combos) + if n > len(combos): + raise ValueError("not enough name combinations for the requested account count") + return combos[:n] + + +def gen_accounts(rng): + names = gen_holder_names(rng, N_ACCOUNTS) + hot_indexes = set(rng.sample(range(N_ACCOUNTS), N_HOT)) + now = datetime.now(timezone.utc) + rows = [] + for i, name in enumerate(names): + account_id = str(uuid.uuid4()) + currency = weighted_choice(rng, CURRENCY_WEIGHTS) + balance = int(rng.lognormvariate(_median_to_mu(250_000), 0.7)) + created_at = now - timedelta(days=rng.uniform(150, 400)) + rows.append( + { + "id": account_id, + "holder_name": name, + "currency": currency, + "balance_minor": balance, + "is_hot": i in hot_indexes, + "created_at": created_at, + } + ) + return rows + + +def _median_to_mu(median): + import math + return math.log(median) + + +def gen_categories(rng): + rows = [] + for i in range(1, N_CATEGORIES + 1): + rows.append( + { + "id": i, + "code": f"cat_{i:02d}", + "label": f"Category {i:02d}", + "risk_weight": round(0.01 + (i - 1) / (N_CATEGORIES - 1) * 0.94, 2), + } + ) + return rows + + +def account_weights(rng, accounts): + """5 hot accounts own ~30% of rows; the remaining 795 share the rest on a + Zipf-ish distribution.""" + hot = [a for a in accounts if a["is_hot"]] + cold = [a for a in accounts if not a["is_hot"]] + hot_weight_each = 0.30 / len(hot) + ranks = list(range(1, len(cold) + 1)) + rng.shuffle(ranks) + zipf_raw = [1.0 / (r ** 1.1) for r in ranks] + zipf_total = sum(zipf_raw) + cold_weights = [0.70 * w / zipf_total for w in zipf_raw] + + ids = [a["id"] for a in hot] + [a["id"] for a in cold] + weights = [hot_weight_each] * len(hot) + cold_weights + return ids, weights + + +def sample_created_at(rng, now, window_days=WINDOW_DAYS): + day_offset = rng.uniform(0, window_days) + day = now - timedelta(days=day_offset) + hour = min(23, max(0, int(rng.gauss(13, 3.5)))) + minute, second = rng.randint(0, 59), rng.randint(0, 59) + dt = day.replace(hour=hour, minute=minute, second=second, microsecond=0) + if dt.weekday() >= 5 and rng.random() < 0.5: + dt -= timedelta(days=2) + return dt + + +def gen_normal_transactions(rng, accounts, now): + ids, weights = account_weights(rng, accounts) + statuses = [("settled", 0.92), ("rejected", 0.05), ("pending", 0.03)] + kinds = [("deposit", 0.2), ("withdrawal", 0.2), ("purchase", 0.6)] + + rows = [] + for _ in range(N_NORMAL): + account_id = rng.choices(ids, weights=weights, k=1)[0] + kind = weighted_choice(rng, kinds) + status = weighted_choice(rng, statuses) + amount = max(1, int(rng.lognormvariate(_median_to_mu(5_000), 0.9))) + risk_score = round(rng.uniform(0, 1), 3) if status != "pending" else None + rows.append( + ( + str(uuid.uuid4()), + account_id, + kind, + amount, + weighted_choice(rng, CURRENCY_WEIGHTS), + rng.choice(MERCHANTS), + rng.randint(1, N_CATEGORIES), + status, + risk_score, + sample_created_at(rng, now), + ) + ) + return rows + + +def gen_malformed_transactions(rng, accounts, poison_account_ids, now): + all_ids = [a["id"] for a in accounts if a["id"] not in poison_account_ids] + rows = [] + poison_tx_ids = {} + + # null_currency: every poison account gets at least one; the rest spread randomly. + remaining = MALFORMED_COUNTS["null_currency"] - len(poison_account_ids) + null_currency_accounts = list(poison_account_ids) + [ + rng.choice(all_ids) for _ in range(remaining) + ] + for account_id in null_currency_accounts: + tx_id = str(uuid.uuid4()) + if account_id in poison_account_ids: + poison_tx_ids[account_id] = tx_id + rows.append( + ( + tx_id, account_id, "purchase", + max(1, int(rng.lognormvariate(_median_to_mu(5_000), 0.9))), + None, rng.choice(MERCHANTS), rng.randint(1, N_CATEGORIES), + "settled", round(rng.uniform(0, 1), 3), sample_created_at(rng, now), + ) + ) + + for _ in range(MALFORMED_COUNTS["negative_deposit"]): + rows.append( + ( + str(uuid.uuid4()), rng.choice(all_ids), "deposit", + -max(1, int(rng.lognormvariate(_median_to_mu(5_000), 0.9))), + weighted_choice(rng, CURRENCY_WEIGHTS), rng.choice(MERCHANTS), + rng.randint(1, N_CATEGORIES), "settled", round(rng.uniform(0, 1), 3), + sample_created_at(rng, now), + ) + ) + + for _ in range(MALFORMED_COUNTS["non_ascii_merchant"]): + merchant = f"{rng.choice(MERCHANTS)} \U0001F600\\x00" + rows.append( + ( + str(uuid.uuid4()), rng.choice(all_ids), "purchase", + max(1, int(rng.lognormvariate(_median_to_mu(5_000), 0.9))), + weighted_choice(rng, CURRENCY_WEIGHTS), merchant, + rng.randint(1, N_CATEGORIES), "settled", round(rng.uniform(0, 1), 3), + sample_created_at(rng, now), + ) + ) + + for _ in range(MALFORMED_COUNTS["future_dated"]): + future = now + timedelta(days=3, hours=rng.uniform(-2, 2)) + rows.append( + ( + str(uuid.uuid4()), rng.choice(all_ids), "purchase", + max(1, int(rng.lognormvariate(_median_to_mu(5_000), 0.9))), + weighted_choice(rng, CURRENCY_WEIGHTS), rng.choice(MERCHANTS), + rng.randint(1, N_CATEGORIES), "pending", None, future, + ) + ) + + for _ in range(MALFORMED_COUNTS["orphan_category"]): + rows.append( + ( + str(uuid.uuid4()), rng.choice(all_ids), "purchase", + max(1, int(rng.lognormvariate(_median_to_mu(5_000), 0.9))), + weighted_choice(rng, CURRENCY_WEIGHTS), rng.choice(MERCHANTS), + 9999, "settled", round(rng.uniform(0, 1), 3), sample_created_at(rng, now), + ) + ) + + return rows, poison_tx_ids + + +def gen_sanctions_entries(rng, sanctioned_holder_names): + rows = [] + for name in sanctioned_holder_names: + rows.append((name, rng.choice(["US", "GB", "FR", "DE", "IR", "RU"]), "OFAC-like-list")) + + fake_first = FIRST_NAMES + ["Zephyrine", "Cassius", "Odalys", "Thaddeus", "Wilhelmina"] + fake_last = LAST_NAMES + ["Voskresenskaya", "Almacita", "Brennholt", "Cortavez"] + used = set(sanctioned_holder_names) + for _ in range(N_SANCTIONS - len(sanctioned_holder_names)): + while True: + name = f"{rng.choice(fake_first)} {rng.choice(fake_last)} {rng.randint(1000,9999)}" + if name not in used: + used.add(name) + break + rows.append((name, rng.choice(["US", "GB", "FR", "DE", "IR", "RU"]), "OFAC-like-list")) + return rows + + +def main(): + rng = random.Random(SEED) + now = datetime.now(timezone.utc) + t0 = time.monotonic() + + accounts = gen_accounts(rng) + hot_accounts = [a for a in accounts if a["is_hot"]] + cold_accounts = [a for a in accounts if not a["is_hot"]] + + role_pool = list(cold_accounts) + rng.shuffle(role_pool) + poison_accounts = role_pool[:N_POISON_ACCOUNTS] + sanctioned_accounts = role_pool[N_POISON_ACCOUNTS : N_POISON_ACCOUNTS + N_SANCTIONED_MATCHES] + clean_account = role_pool[N_POISON_ACCOUNTS + N_SANCTIONED_MATCHES] + cold_sample = role_pool[N_POISON_ACCOUNTS + N_SANCTIONED_MATCHES + 1 : N_POISON_ACCOUNTS + N_SANCTIONED_MATCHES + 61] + + poison_account_ids = [a["id"] for a in poison_accounts] + sanctioned_account_ids = [a["id"] for a in sanctioned_accounts] + hot_account_ids = [a["id"] for a in hot_accounts] + cold_account_id_sample = [a["id"] for a in cold_sample] + clean_account_id = clean_account["id"] + + categories = gen_categories(rng) + normal_txs = gen_normal_transactions(rng, accounts, now) + malformed_txs, poison_tx_ids = gen_malformed_transactions(rng, accounts, set(poison_account_ids), now) + all_txs = normal_txs + malformed_txs + rng.shuffle(all_txs) + + sanctions_entries = gen_sanctions_entries(rng, [a["holder_name"] for a in sanctioned_accounts]) + + print(f"generated {len(accounts)} accounts, {len(all_txs)} transactions, " + f"{len(categories)} categories, {len(sanctions_entries)} sanctions entries " + f"in {time.monotonic() - t0:.1f}s (SEED={SEED})") + + with psycopg.connect(DSN, autocommit=False) as conn: + with conn.cursor() as cur: + cur.execute( + "TRUNCATE accounts, merchant_categories, transactions, sanctions_entries, " + "notifications, audit_events RESTART IDENTITY" + ) + + with cur.copy( + "COPY accounts (id, holder_name, currency, balance_minor, is_hot, created_at) FROM STDIN" + ) as copy: + for a in accounts: + copy.write_row((a["id"], a["holder_name"], a["currency"], a["balance_minor"], a["is_hot"], a["created_at"])) + + with cur.copy( + "COPY merchant_categories (id, code, label, risk_weight) FROM STDIN" + ) as copy: + for c in categories: + copy.write_row((c["id"], c["code"], c["label"], c["risk_weight"])) + + with cur.copy( + "COPY transactions (id, account_id, kind, amount_minor, currency, merchant_name, " + "category_id, status, risk_score, created_at) FROM STDIN" + ) as copy: + for row in all_txs: + copy.write_row(row) + + with cur.copy( + "COPY sanctions_entries (normalized_name, country, list_source) FROM STDIN" + ) as copy: + for row in sanctions_entries: + copy.write_row(row) + + cur.execute("ANALYZE accounts, transactions, merchant_categories, sanctions_entries") + + manifest = { + "seed": SEED, + "generated_at": now.isoformat(), + "counts": { + "accounts": len(accounts), + "transactions": len(all_txs), + "merchant_categories": len(categories), + "sanctions_entries": len(sanctions_entries), + }, + "hot_account_ids": hot_account_ids, + "poison_account_ids": poison_account_ids, + "sanctioned_account_ids": sanctioned_account_ids, + "clean_account_id": clean_account_id, + "cold_account_ids_sample": cold_account_id_sample, + "malformed_row_count": len(malformed_txs), + } + cur.execute( + "INSERT INTO seed_manifest (id, data) VALUES (1, %s::jsonb) " + "ON CONFLICT (id) DO UPDATE SET data = EXCLUDED.data", + (json.dumps(manifest),), + ) + conn.commit() + + if poison_tx_ids: + import redis as redis_sync + + redis_url = os.environ.get("REDIS_URL", "redis://localhost:6379/0") + r = redis_sync.from_url(redis_url, decode_responses=True) + r.delete("poison:tx_by_account") + r.hset("poison:tx_by_account", mapping=poison_tx_ids) + + elapsed = time.monotonic() - t0 + print(f"seed complete in {elapsed:.1f}s") + print("row counts:") + for table, count in manifest["counts"].items(): + print(f" {table}: {count}") + print(f"hot account ids: {hot_account_ids}") + print(f"poison account ids: {poison_account_ids[:5]}... ({len(poison_account_ids)} total)") + print(f"malformed rows: {manifest['malformed_row_count']}") + print(f"clean_account_id: {clean_account_id}") + + +if __name__ == "__main__": + main() diff --git a/scripts/smoke.py b/scripts/smoke.py new file mode 100644 index 0000000..07ae010 --- /dev/null +++ b/scripts/smoke.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""One transaction, one insights call, assert 200s -- phase-0-base-project-spec.md §12.""" +import json +import os +import sys +import urllib.request + +import psycopg + +GATEWAY_URL = os.environ.get("GATEWAY_URL", "http://localhost:8000") +DSN = os.environ.get("POSTGRES_DSN", "postgresql://ledgerline:ledgerline@localhost:5432/ledgerline") + + +def _get(url): + with urllib.request.urlopen(url, timeout=10) as resp: + return resp.status, json.loads(resp.read()) + + +def _post(url, payload): + data = json.dumps(payload).encode() + req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}, method="POST") + try: + with urllib.request.urlopen(req, timeout=10) as resp: + return resp.status, json.loads(resp.read()) + except urllib.error.HTTPError as exc: + return exc.code, json.loads(exc.read()) + + +def main(): + with psycopg.connect(DSN, autocommit=True) as conn: + with conn.cursor() as cur: + cur.execute("SELECT data FROM seed_manifest LIMIT 1") + row = cur.fetchone() + if not row: + print("smoke: no seed manifest found -- run `make seed` first", file=sys.stderr) + sys.exit(1) + account_id = row[0]["clean_account_id"] + + status, body = _post( + f"{GATEWAY_URL}/api/transactions", + { + "account_id": account_id, + "kind": "deposit", + "amount_minor": 500, + "currency": "EUR", + "merchant_name": "Smoke Test Merchant", + }, + ) + assert status == 201, f"transaction failed: {status} {body}" + print(f"transaction ok: {body['transaction_id']} status={body['status']}") + + status, body = _get(f"{GATEWAY_URL}/api/accounts/{account_id}/insights") + assert status == 200, f"insights failed: {status} {body}" + print(f"insights ok: {body}") + + print("smoke test passed") + + +if __name__ == "__main__": + main() diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/audit/__init__.py b/services/audit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/audit/consumer.py b/services/audit/consumer.py new file mode 100644 index 0000000..e28dd16 --- /dev/null +++ b/services/audit/consumer.py @@ -0,0 +1,68 @@ +import asyncio +import json +import logging + +import sqlalchemy as sa + +from common.db import Session +from common.redis_client import redis_client +from common.settings import settings + +logger = logging.getLogger("audit") + +GROUP = "audit" +CONSUMER = "audit-1" + + +async def ensure_group(): + try: + await redis_client.xgroup_create(settings.stream_name, GROUP, id="$", mkstream=True) + except Exception as exc: + if "BUSYGROUP" not in str(exc): + raise + + +async def _handle(fields: dict): + async with Session() as session: + await session.execute( + sa.text( + "INSERT INTO audit_events (transaction_id, event_type, payload, recorded_at) " + "VALUES (:tx_id, 'transaction_processed', CAST(:payload AS jsonb), now())" + ), + {"tx_id": fields.get("transaction_id"), "payload": json.dumps(fields)}, + ) + await session.commit() + + +async def run(): + """Second, independent consumer group on the same stream as `notifier` -- + proves the async fork fans out to genuinely separate consumers, not one + consumer doing both jobs.""" + await ensure_group() + logger.info("audit consumer started") + while True: + try: + resp = await redis_client.xreadgroup( + groupname=GROUP, + consumername=CONSUMER, + streams={settings.stream_name: ">"}, + count=10, + block=2000, + ) + except asyncio.CancelledError: + raise + except Exception: + logger.exception("audit consumer read failed") + await asyncio.sleep(1) + continue + + if not resp: + continue + + for _, messages in resp: + for msg_id, fields in messages: + try: + await _handle(fields) + except Exception: + logger.exception(f"audit failed to process message {msg_id}") + await redis_client.xack(settings.stream_name, GROUP, msg_id) diff --git a/services/audit/worker.py b/services/audit/worker.py new file mode 100644 index 0000000..20c8575 --- /dev/null +++ b/services/audit/worker.py @@ -0,0 +1,10 @@ +import asyncio + +from common.logging_config import configure_logging + +from . import consumer + +configure_logging("audit") + +if __name__ == "__main__": + asyncio.run(consumer.run()) diff --git a/services/fraud/__init__.py b/services/fraud/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/fraud/app.py b/services/fraud/app.py new file mode 100644 index 0000000..2ff72ea --- /dev/null +++ b/services/fraud/app.py @@ -0,0 +1,77 @@ +import httpx +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse +from pydantic import BaseModel + +from common.http import downstream_client +from common.logging_config import configure_logging +from common.redis_client import redis_client +from common.settings import settings + +from . import chaos +from .scoring import score + +SERVICE_NAME = "fraud" +logger = configure_logging(SERVICE_NAME) +app = FastAPI(title=SERVICE_NAME) + + +class ScoreRequest(BaseModel): + account_id: str + holder_name: str + category_id: int | None = None + amount_minor: int + request_id: str = "unknown" + + +@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: + await redis_client.ping() + except Exception as exc: + logger.exception(f"readyz check failed: {exc}") + return JSONResponse(status_code=503, content={"status": "unavailable"}) + return {"status": "ok"} + + +@app.post("/internal/score") +async def score_transaction(body: ScoreRequest): + logger.info(f"scoring account {body.account_id} request_id={body.request_id}") + await redis_client.incr("fraud:calls_total") + + if await chaos.blocking_event_loop_enabled(): + logger.info(f"blocking_event_loop chaos engaged for request_id={body.request_id}") + chaos.synchronous_cpu_work() + + async with downstream_client(body.request_id) as client: + try: + resp = await client.post( + f"{settings.sanctions_url}/internal/lookup", + json={"holder_name": body.holder_name, "request_id": body.request_id}, + ) + except httpx.TimeoutException: + logger.exception(f"sanctions lookup timed out for request_id={body.request_id}") + return JSONResponse(status_code=504, content={"error": "sanctions lookup timed out"}) + except httpx.RequestError as exc: + logger.exception(f"sanctions lookup failed for request_id={body.request_id}: {exc}") + return JSONResponse(status_code=502, content={"error": "sanctions lookup failed"}) + + if resp.status_code >= 300: + logger.info(f"sanctions returned {resp.status_code} for request_id={body.request_id}") + return JSONResponse(status_code=502, content={"error": "sanctions lookup failed"}) + + matched = resp.json().get("matched", False) + risk_score = score(body.category_id, matched) + return {"risk_score": risk_score, "matched": matched} diff --git a/services/fraud/chaos.py b/services/fraud/chaos.py new file mode 100644 index 0000000..1b0273c --- /dev/null +++ b/services/fraud/chaos.py @@ -0,0 +1,22 @@ +"""blocking_event_loop -- phase-0-base-project-spec.md §7. Owned by `fraud`.""" +import hashlib +import time + +from common.chaos_flags import is_enabled + +_CPU_WORK_SECONDS = 0.15 + + +async def blocking_event_loop_enabled() -> bool: + return await is_enabled("blocking_event_loop") + + +def synchronous_cpu_work() -> None: + """~120ms of real, synchronous, CPU-bound work run directly inside an + `async def` handler -- no run_in_executor/to_thread. This is the point: + it blocks the whole event loop, serialising every other in-flight + request on this worker for the duration.""" + deadline = time.perf_counter() + _CPU_WORK_SECONDS + data = b"ledgerline-fraud-scoring" + while time.perf_counter() < deadline: + data = hashlib.sha256(data).digest() diff --git a/services/fraud/scoring.py b/services/fraud/scoring.py new file mode 100644 index 0000000..72b0848 --- /dev/null +++ b/services/fraud/scoring.py @@ -0,0 +1,18 @@ +import random + + +def category_weight(category_id: int | None) -> float: + """A fake but deterministic-ish stand-in for merchant_categories.risk_weight + (fraud has no direct DB access -- Redis only, per phase-0-base-project-spec.md §4). + The business logic does not need to be correct, only to produce a plausible + spread of scores.""" + if category_id is None: + return 0.3 + return 0.01 + (category_id % 45) / 44 * 0.94 + + +def score(category_id: int | None, matched: bool) -> float: + base = category_weight(category_id) * random.uniform(0.05, 0.3) + if matched: + return min(1.0, base + random.uniform(0.55, 0.9)) + return min(1.0, base) diff --git a/services/gateway/__init__.py b/services/gateway/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/gateway/app.py b/services/gateway/app.py new file mode 100644 index 0000000..5a6e3f1 --- /dev/null +++ b/services/gateway/app.py @@ -0,0 +1,151 @@ +import asyncio +import uuid +from pathlib import Path + +import httpx +from fastapi import FastAPI, Request +from fastapi.responses import FileResponse, JSONResponse +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel, field_validator + +from common.chaos_flags import ALL_FLAGS, get_flags, set_flag, set_profile +from common.logging_config import configure_logging +from common.settings import settings + +from . import orchestrator +from .orchestrator import UpstreamFailure + +SERVICE_NAME = "gateway" +logger = configure_logging(SERVICE_NAME) +app = FastAPI(title=SERVICE_NAME) + +STATIC_DIR = Path(__file__).parent / "static" +app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") + +# A shared, connection-pooling client for the simple read-only proxy routes +# below -- opening a fresh httpx.AsyncClient (and TCP connection) per request +# is real, needless overhead under concurrent load. orchestrator.py's +# downstream_client() stays per-request since it carries per-transaction +# headers (X-Request-Id). +_proxy_client = httpx.AsyncClient( + timeout=settings.downstream_timeout_seconds, + limits=httpx.Limits(max_connections=100, max_keepalive_connections=50), +) + +DOWNSTREAM_URLS = { + "ledger": settings.ledger_url, + "fraud": settings.fraud_url, + "sanctions": settings.sanctions_url, + "insights": settings.insights_url, + "notifier": settings.notifier_url, +} + + +class TransactionRequest(BaseModel): + account_id: str + kind: str + amount_minor: int + currency: str + merchant_name: str + category_id: int | None = None + + @field_validator("kind") + @classmethod + def kind_must_be_known(cls, v): + if v not in ("deposit", "withdrawal", "purchase"): + raise ValueError("kind must be one of deposit, withdrawal, purchase") + return v + + @field_validator("amount_minor") + @classmethod + def deposit_amount_must_be_non_negative(cls, v, info): + if info.data.get("kind") == "deposit" and v < 0: + raise ValueError("amount_minor must be non-negative for a deposit") + return v + + +class ChaosFlagRequest(BaseModel): + flag: str + enabled: bool + + +class ChaosProfileRequest(BaseModel): + profile: str + + +@app.get("/", include_in_schema=False) +async def index(): + """Serves the frontend (phase-0-base-project-spec.md §10). This overrides + §4's generic `GET / -> {"service", "version"}` info route, which the + other five HTTP services still expose as-is.""" + return FileResponse(STATIC_DIR / "index.html") + + +@app.get("/healthz") +async def healthz(): + return {"status": "ok", "service": SERVICE_NAME} + + +@app.get("/readyz") +async def readyz(): + return {"status": "ok"} + + +@app.get("/api/accounts") +async def list_accounts(): + resp = await _proxy_client.get(f"{settings.ledger_url}/internal/accounts") + return JSONResponse(status_code=resp.status_code, content=resp.json()) + + +@app.get("/api/accounts/{account_id}/insights") +async def account_insights(account_id: str): + resp = await _proxy_client.get(f"{settings.insights_url}/internal/accounts/{account_id}/summary") + return JSONResponse(status_code=resp.status_code, content=resp.json()) + + +@app.post("/api/transactions", status_code=201) +async def submit_transaction(body: TransactionRequest): + request_id = str(uuid.uuid4()) + try: + result = await orchestrator.create_transaction(body.model_dump(), request_id) + except UpstreamFailure as exc: + return JSONResponse( + status_code=exc.status_code, content={"error": exc.message, "request_id": request_id} + ) + return JSONResponse(status_code=201, content=result) + + +@app.get("/api/health/all") +async def health_all(): + async def check(name, url): + try: + resp = await _proxy_client.get(f"{url}/healthz", timeout=2.0) + return name, "ok" if resp.status_code == 200 else "down" + except httpx.RequestError: + return name, "down" + + results = await asyncio.gather(*[check(name, url) for name, url in DOWNSTREAM_URLS.items()]) + services = {"gateway": "ok", **dict(results)} + return {"services": services} + + +@app.get("/api/admin/chaos") +async def get_chaos(): + return {"flags": await get_flags()} + + +@app.post("/api/admin/chaos") +async def post_chaos(body: ChaosFlagRequest): + if body.flag not in ALL_FLAGS: + return JSONResponse(status_code=400, content={"error": f"unknown flag {body.flag!r}"}) + await set_flag(body.flag, body.enabled) + return {"flags": await get_flags()} + + +@app.post("/api/admin/chaos/profile") +async def post_chaos_profile(body: ChaosProfileRequest): + try: + flags = await set_profile(body.profile) + except ValueError as exc: + return JSONResponse(status_code=400, content={"error": str(exc)}) + return {"flags": flags} diff --git a/services/gateway/chaos.py b/services/gateway/chaos.py new file mode 100644 index 0000000..4e21860 --- /dev/null +++ b/services/gateway/chaos.py @@ -0,0 +1,23 @@ +"""retry_storm, no_batching, chatty_health -- phase-0-base-project-spec.md §7. +All three are owned by `gateway`.""" +import random + +from common.chaos_flags import is_enabled + +DOWNSTREAMS = ("ledger", "fraud", "sanctions", "insights", "notifier") + + +async def retry_storm_enabled() -> bool: + return await is_enabled("retry_storm") + + +async def no_batching_enabled() -> bool: + return await is_enabled("no_batching") + + +async def chatty_health_enabled() -> bool: + return await is_enabled("chatty_health") + + +def synthetic_line_item_count() -> int: + return random.randint(1, 5) diff --git a/services/gateway/orchestrator.py b/services/gateway/orchestrator.py new file mode 100644 index 0000000..066bc2b --- /dev/null +++ b/services/gateway/orchestrator.py @@ -0,0 +1,167 @@ +import logging +import time +import uuid + +import httpx + +from common.http import downstream_client +from common.redis_client import redis_client +from common.settings import settings + +from . import chaos + +logger = logging.getLogger("gateway") + +REJECT_THRESHOLD = 0.8 + + +class UpstreamFailure(Exception): + def __init__(self, status_code: int, message: str): + self.status_code = status_code + self.message = message + + +async def _post(client: httpx.AsyncClient, url: str, json: dict) -> httpx.Response: + try: + return await client.post(url, json=json) + except httpx.TimeoutException as exc: + raise UpstreamFailure(504, f"{url} timed out") from exc + except httpx.RequestError as exc: + raise UpstreamFailure(502, f"{url} unreachable") from exc + + +async def _score_once(client: httpx.AsyncClient, payload: dict) -> dict: + resp = await _post(client, f"{settings.fraud_url}/internal/score", payload) + if resp.status_code >= 300: + status = resp.status_code if resp.status_code in (502, 504) else 502 + raise UpstreamFailure(status, "fraud scoring failed") + return resp.json() + + +async def _score_with_retry(client: httpx.AsyncClient, payload: dict, retry_storm: bool) -> dict: + """retry_storm: on any non-2xx from fraud, retry 3 times immediately -- + no backoff, no jitter, no circuit breaker (phase-0-base-project-spec.md §7). + "No circuit breaker" is taken literally here: once triggered, it fires all + 3 retries unconditionally rather than stopping early on the first success + -- it never checks whether it still needs to. The last attempt's outcome + wins.""" + attempts = 4 if retry_storm else 1 + result = None + last_exc = None + for _ in range(attempts): + try: + result = await _score_once(client, payload) + last_exc = None + except UpstreamFailure as exc: + last_exc = exc + if last_exc is not None: + raise last_exc + return result + + +async def _run_chatty_health_probe(client: httpx.AsyncClient) -> None: + """chatty_health: GET /healthz on all five downstreams, sequentially, + before doing any real work.""" + headers = {"X-Ledgerline-Probe": "chatty-health"} + for name in chaos.DOWNSTREAMS: + url = getattr(settings, f"{name}_url") + try: + await client.get(f"{url}/healthz", headers=headers, timeout=2.0) + except httpx.RequestError: + pass + + +async def create_transaction(payload: dict, request_id: str) -> dict: + start = time.perf_counter() + logger.info(f"start transaction for account {payload['account_id']} request_id={request_id}") + + retry_storm = await chaos.retry_storm_enabled() + no_batching = await chaos.no_batching_enabled() + chatty_health = await chaos.chatty_health_enabled() + + async with downstream_client(request_id) as client: + if chatty_health: + await _run_chatty_health_probe(client) + + create_resp = await _post( + client, + f"{settings.ledger_url}/internal/transactions", + {**payload, "request_id": request_id}, + ) + if create_resp.status_code == 404: + raise UpstreamFailure(404, "account not found") + if create_resp.status_code >= 500: + raise UpstreamFailure(create_resp.status_code, "ledger failed unexpectedly") + if create_resp.status_code >= 300: + raise UpstreamFailure(502, "ledger rejected the transaction") + + created = create_resp.json() + tx_id = created["transaction_id"] + + score_payload = { + "account_id": created["account_id"], + "holder_name": created["holder_name"], + "category_id": created["category_id"], + "request_id": request_id, + } + + if no_batching: + item_count = chaos.synthetic_line_item_count() + scores = [] + for _ in range(item_count): + item_payload = {**score_payload, "amount_minor": max(1, created["amount_minor"] // item_count)} + scores.append(await _score_with_retry(client, item_payload, retry_storm)) + risk_score = max(s["risk_score"] for s in scores) + else: + score_payload["amount_minor"] = created["amount_minor"] + scored = await _score_with_retry(client, score_payload, retry_storm) + risk_score = scored["risk_score"] + + decision = "rejected" if risk_score > REJECT_THRESHOLD else "settled" + action = "reject" if decision == "rejected" else "settle" + settle_resp = await _post( + client, + f"{settings.ledger_url}/internal/transactions/{tx_id}/{action}", + {"risk_score": risk_score, "request_id": request_id}, + ) + if settle_resp.status_code >= 300: + raise UpstreamFailure(502, "ledger failed to finalise the transaction") + settled = settle_resp.json() + + try: + await redis_client.xadd( + settings.stream_name, + { + "transaction_id": tx_id, + "account_id": created["account_id"], + "kind": created["kind"], + "amount_minor": str(created["amount_minor"]), + "status": settled["status"], + "risk_score": str(risk_score), + }, + ) + except Exception: + logger.exception(f"failed to publish transaction {tx_id} to the stream") + + summary = {} + try: + summary_resp = await client.get( + f"{settings.insights_url}/internal/accounts/{created['account_id']}/quick_summary" + ) + if summary_resp.status_code == 200: + summary = summary_resp.json() + except httpx.RequestError: + logger.exception(f"insights summary unavailable for request_id={request_id}") + + elapsed_ms = (time.perf_counter() - start) * 1000 + logger.info(f"finished transaction {tx_id} status={settled['status']} request_id={request_id}") + + return { + "transaction_id": tx_id, + "status": settled["status"], + "risk_score": risk_score, + "balance_minor": settled["balance_minor"], + "summary": summary, + "request_id": request_id, + "elapsed_ms": elapsed_ms, + } diff --git a/services/gateway/static/app.js b/services/gateway/static/app.js new file mode 100644 index 0000000..183d2e7 --- /dev/null +++ b/services/gateway/static/app.js @@ -0,0 +1,149 @@ +const accountSelect = document.getElementById("account-select"); +const txForm = document.getElementById("tx-form"); +const resultJson = document.getElementById("result-json"); +const resultRtt = document.getElementById("result-rtt"); +const resultRequestId = document.getElementById("result-request-id"); +const burstButton = document.getElementById("burst-button"); +const burstN = document.getElementById("burst-n"); +const burstTableBody = document.querySelector("#burst-table tbody"); +const chaosFlagsDiv = document.getElementById("chaos-flags"); +const healthStrip = document.getElementById("health-strip"); + +async function loadAccounts() { + const resp = await fetch("/api/accounts"); + const accounts = await resp.json(); + accountSelect.innerHTML = accounts + .slice(0, 200) + .map((a) => ``) + .join(""); +} + +txForm.addEventListener("submit", async (e) => { + e.preventDefault(); + const payload = { + account_id: accountSelect.value, + kind: document.querySelector('input[name="kind"]:checked').value, + amount_minor: Number(document.getElementById("amount-input").value), + currency: "EUR", + merchant_name: document.getElementById("merchant-input").value, + }; + const start = performance.now(); + const resp = await fetch("/api/transactions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const rtt = performance.now() - start; + const body = await resp.json(); + resultRtt.textContent = rtt.toFixed(1); + resultRequestId.textContent = body.request_id || "--"; + resultJson.textContent = JSON.stringify(body, null, 2); +}); + +function percentile(sorted, p) { + if (sorted.length === 0) return 0; + const rank = Math.max(1, Math.min(sorted.length, Math.ceil(p * sorted.length))); + return sorted[rank - 1]; +} + +burstButton.addEventListener("click", async () => { + const n = Number(burstN.value); + const accounts = Array.from(accountSelect.options).map((o) => o.value); + burstTableBody.innerHTML = `
a deliberately imperfect payments backend
+Round-trip: -- ms · request_id: --
(nothing yet)+
| count | success | errors | min | mean | p50 | p95 | max |
|---|---|---|---|---|---|---|---|
| no burst run yet | |||||||