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,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)
|
||||
@@ -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)
|
||||
@@ -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},
|
||||
)
|
||||
@@ -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)
|
||||
@@ -0,0 +1,5 @@
|
||||
import redis.asyncio as redis
|
||||
|
||||
from .settings import settings
|
||||
|
||||
redis_client = redis.from_url(settings.redis_url, decode_responses=True)
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user