stacken.docs

Styrning för en växande portfölj av AI-tjänster

stacken.ai ↗

Ur repot

Kapacitetsmodell — #T6 skalgrind

Exekveringsplanens #T6 (intern plan) kräver en “dokumenterad kapacitetsmodell (pods, DB-anslutningar, provider-limits)” incheckad i docs. Detta är den dokumenten. Siffrorna nedan är verifierade direkt mot charts/*/values.yaml, charts/*/values-prod.yaml och tjänsternas källkod den 2026-07-22/23 (Wave 2, Plan C, en intern plan), inte tagna oprövade ur receptet. Grunden är .context/wave2-research-scale.md (fasstartens recon). Uppdatera vid nästa fasstart-kartläggning om siffrorna glidit — atlasen (docs/atlas.md) är kartan, koden är sanningen.

1. Pods och repliker

autoscaling.enabled (default false) och en HorizontalPodAutoscaler-mall (scaleTargetRef → tjänstens Deployment, CPU-utilization-mål) finns nu i alla 11 charts (Wave 2 Task 4). 7 av 11 har en values-prod.yaml som slår på autoskalning; 4 saknar prod-overlay helt.

Tjänst values.yaml (dev, replicaCount) values-prod.yaml (replicaCount) Autoscaling (prod, min/max, targetCPU)
auth-api 2 (ingen prod-overlay) disabled
bff 2 3 enabled, min 3 / max 6, 70 %
eval-api 2 (ingen prod-overlay) disabled
feedback-api 2 2 enabled, min 2 / max 4, 70 %
lifecycle-api 2 (ingen prod-overlay) disabled
llm-gateway 2 3 enabled, min 3 / max 6, 70 %
mcp-gateway 2 (prod-overlay finns, men bara config.rateLimitStorageUri — inget replicaCount, ingen autoscaling-nyckel) disabled
pii-api 2 2 enabled, min 2 / max 4, 70 %
policy-engine 2 3 enabled, min 3 / max 6, 70 %
provisioning-api 2 2 enabled, min 2 / max 4, 70 %
rag-api 2 2 enabled, min 2 / max 4, 70 %

Alla 11 charts är kind: Deployment (ingen StatefulSet, ingen PVC) — varje tjänst är statslös på pod-nivå; korrekthetsbärande state (rate limits, budgetar, audit-kedjor) ligger uttryckligen i delad Postgres/Valkey, se §2–§3.

Öppen lucka (flaggad, INTE åtgärdad i denna omgång): auth-api, eval-api, lifecycle-api och mcp-gateway saknar en fungerande values-prod.yaml-autoskalningskonfiguration — de kan inte autoskala i prod förrän en riktig prod-overlay finns (mcp-gatewayens values-prod.yaml tillkom i Wave 2 Task 1 enbart för att bära rateLimitStorageUri; den bär ingen replika-/autoskalningskonfiguration). Att bygga fyra tjänsters prod-topologi låg utanför #T6:s scope (CLAUDE.md surgical-changes-regeln) — nästa naturliga uppföljning.

2. DB-anslutningar — den enskilt största kapacitetsrisken

Ingen tjänst tunar pool_size/max_overflow för sin huvudpool. Verifierat rad för rad i varje tjänsts app/db.py: auth-api, bff, eval-api, feedback-api, lifecycle-api, policy-engine, provisioning-api, rag-api och mcp-gateways båda motorer (lokal DB + platform_pg_url) anropar alla create_async_engine(url, pool_pre_ping=True) — inga pool_size/max_overflow-argument. SQLAlchemys default är pool_size=5 + max_overflow=10 = 15 anslutningar per pod-instans. Den enda explicita pool_size-tuningen i hela flottan är mcp-gatewayens hälsokontrollmotor (services/mcp-gateway/app/routes/health.py:39,53, pool_size=1, max_overflow=0) — en separat, kortlivad engine bara för /readyz, inte appens huvudpool.

llm-gateway kör ingen SQLAlchemy alls — en rå asyncpg-pool (gateway/db.py:12-15, init_pool(dsn, min_size=2, max_size=10), anropad utan overrides från gateway/main.py:112) → 10 anslutningar per pod, inte 15.

Vilken Postgres-instans går anslutningarna mot?

Enligt D-50 (docs/decisions.md:462) delar 8 av de 9 tillståndsbärande tjänsterna (auth-api, bff, eval-api, feedback-api, lifecycle-api, llm-gateway, policy-engine, provisioning-api) samma platform-pg-instans. rag-api är ett undantag — den migrerar och kör mot sin egen rag-pg (charts/rag-api/values.yaml:108: “rag-api migrerar sin egen tenant-lokala rag-pg, oberoende av platform-pg-kedjan”), så dess anslutningar konkurrerar inte om platform-pg:s max_connections. provisioning-api är dessutom dual-DB (bootstrap:ar även tenants-pg, ett mindre anslutningsavtryck utanför denna modell). mcp-gateway migrerar inget eget schema på platform-pg (D-50 räknar den bort ur “de 9”), men öppnar ändå en egen platform_pg_url-engine (app/platform_db.py) för audit-läsningar — den är alltså en anslutningskonsument mot platform-pg, bara utan egen migrations-wave. pii-api saknar helt Postgres-kod (statslös, ingen db.py).

Räkneexempel

Forskningens schablonsiffra (uniform övre gräns, .context/wave2-research-scale.md): 9 tjänster × 15 anslutningar/pod × upp till 6 repliker (om alla samtidigt låg vid den högsta maxReplicas som förekommer i flottan) ≈ 810 anslutningar.

En skarpare, per-tjänst-verifierad övre gräns mot platform-pg — varje tjänsts faktiska maxReplicas (eller replicaCount där autoskalning saknas) × dess faktiska pool-storlek:

Tjänst Repliker vid tak Anslutningar/pod Delsumma
bff 6 15 90
policy-engine 6 15 90
llm-gateway 6 10 (asyncpg) 60
feedback-api 4 15 60
provisioning-api 4 15 60
auth-api 2 (ingen autoskalning) 15 30
eval-api 2 (ingen autoskalning) 15 30
lifecycle-api 2 (ingen autoskalning) 15 30
mcp-gateway (platform_pg_url-motorn) 2 (ingen autoskalning) 15 30
Summa mot platform-pg ≈ 480

(rag-api tillkommer separat mot sin egen rag-pg: upp till 4 repliker × 15 = 60 — samma otunade-default-risk, fast mot en annan instans och en mindre absolut siffra.)

Oavsett vilken siffra man använder (810 schablonmässigt, eller 480 mot den faktiska platform-pg-uppsättningen) är resultatet flera gånger högre än en okonfigurerad Postgres — max_connections sätts ingenstans i detta repo (grep -rn max_connections hittar inga träffar i charts/, services/*/app/, eller docs utöver den här sortens analys); PostgreSQL 16:s inbyggda default är 100. Vare sig platform-pg eller rag-pg har någon dokumenterad max_connections-tuning på klustersidan (CNPG/plattform-sidan, D-49 — Postgres provisioneras utanför dessa charts, så den tuningen ligger utanför detta repos kontroll).

Detta är en öppen fråga till plattform-sidan, inte löst av denna plan. Rekommenderad uppföljning (endera eller båda):

  1. Höj max_connections på platform-pg- och rag-pg-CNPG-instanserna till att rymma den teoretiska toppen (eller sätt en pooler framför, t.ex. pgbouncer i transaction-läge, så appens pool_size blir frikopplad från antalet fysiska backend-anslutningar).
  2. Sänk max_overflow/pool_size explicit per tjänst i create_async_engine(...)-anropen (t.ex. pool_size=5, max_overflow=0 → 5/pod istället för 15/pod — sänker platform-pg-taket från ≈480 till ≈160 vid samma replikatak).

Ingen av dessa är byggda i denna omgång — flaggat, inte åtgärdat.

3. Rate limiting — global enforcement (#T6 kriterium 2)

Tjänst Limiter Storage (Wave 2) Bakgrund
bff Limiter(key_func=platform_key), en hink per rutt för hela plattformen (D-169) RATE_LIMIT_STORAGE_URI (Valkey i prod) var memory:// implicit, per pod
feedback-api build_limiter(...) samma var storage_uri="memory://" explicit
mcp-gateway (REST /v1/tools/call) Limiter(key_func=_tenant_key) samma var memory:// implicit
mcp-gateway (MCP-dörren /mcp) — inre RateLimitedASGI(tenant_key), TENANT_RATE_LIMIT_RPS (100) samma D-76; per tenant, innanför auth
mcp-gateway (MCP-dörren /mcp) — yttre RateLimitedASGI(ip_key), MCP_DOOR_IP_RATE_LIMIT_RPS (2000) samma D-76; se varningen nedan
auth-api Limiter(key_func=platform_key) och OAuth-dörrens _NamedASGIEndpoint, en hink per rutt för hela plattformen samma, nyinstansierad (Task 3) D-169: överbelastningsskydd, inte per klient; per konto bromsas i account_backoff.py

Varning — dörrens yttre lager är i praktiken ett FLOTTVITT tak. Det nycklar på peer-IP och läser medvetet inte X-Forwarded-For (spoofbar — se D-76). Bakom en ingress är peer-IP:n ingressens, så alla tenants och alla poddar delar en enda hink. Det gör värdet till ett DoS-tak för hela MCP-ytan, inte ett per-klient-tak, och det måste därför hållas över summan av den legitima trafik man förväntar sig: MCP_DOOR_IP_RATE_LIMIT_RPS > (antal samtidigt aktiva tenants) × TENANT_RATE_LIMIT_RPS. Defaulten 2000 räcker för ~20 tenants på fullt tenant-tak. Sätts den för lågt börjar det yttre lagret 429:a legitim autentiserad trafik innan något tenant-tak nås — och felet syns som slumpmässiga 429 hos kunder som inte gjort något fel. Ursprungsförslaget 500 hade bundit redan vid sex tenants; det fångades i arkitekturgranskningen av D-76 och höjdes.

Ej bevisat cross-pod. tests/load/test_global_rate_limit_proof.py bevisar global verkställighet för slowapi-vägen. De två nya ASGI-lagren är en annan mekanism och är i dag bara prövade mot memory:// i process. De delar Valkey via samma RATE_LIMIT_STORAGE_URI, men ett exakt-N-bevis i samma form saknas — registrerat som D-76 Kvarstår. | llm-gateway | RateLimiter (gateway/ratelimit.py), limits med glidande fönster per API-nyckel | samma (D-175) | var en token-bucket per pod, alltså default_rpm × repliker |

Sedan Wave 2 (Task 1–3) pekar bff, feedback-api, mcp-gateway och auth-api alla sin RATE_LIMIT_STORAGE_URI mot en delad Valkey (redis://platform-valkey:6379/0 i respektive values-prod.yaml; BSD-3-Clause, drop-in Redis-protokoll-server — se docs/decisions.md för varför Valkey och inte Redis Inc:s post-2024 source-available Redis). Det gör att rate-limiten gäller globalt över alla pods för dessa fyra tjänster, inte per pod.

Paket 6 (2026-07-25) stängde den tysta degraderingen. Fram till dess lästes URI:n med os.environ.get("RATE_LIMIT_STORAGE_URI", "memory://") — förbi pydantic-Settings. En felstavad eller bortglömd URI föll då tillbaka på per-pod-minne utan att det syntes någonstans: rate-limiten såg konfigurerad ut men gällde bara per pod. Det bryter korrektheten i skyddet, inte bara prestandan, vilket CLAUDE.md:s skalgrundkrav uttryckligen förbjuder (“per-pod-optimeringar får degradera skyddet, aldrig bryta korrektheten”).

Numera är URI:n ett Settings-fält (rate_limit_storage_uri) i alla fyra tjänsterna, och flaggan require_shared_rate_limit — satt till true i respektive values-prod.yaml — gör att memory:// fäller starten i stället för att degradera. Default är false, så lokalt och i test är beteendet oförändrat. charts/auth-api/values-prod.yaml skapades i samma veva och bär i dag enbart dessa två värden; auth-apis övriga prod-överlägg (autoscaling m.m.) saknas fortfarande — se §11 i core-och-gränssnittsmodell-specen.

Bevis (Task 2, tests/load/test_global_rate_limit_proof.py): två oberoende slowapi.Limiter-objekt (samma klass, samma .limiter.hit(...)-anrop som varje tjänst själv gör per request) som simulerar två pods bakom en delad Valkey-container (testcontainers), _LIMIT_N=20, 30 requests/pod interfolierat. Deterministiskt utfall (samma nyckel, samma limit-item — inget race-fönster i denna testkonstruktion):

  • Delad Valkey-storage: admitted=20 (exakt 20 — INTE nära 40; kriterium 2 uppfyllt).
  • Negativt kontrollprov (samma uppställning, memory:// på båda “podarna” — bevisar att testet faktiskt fångar anti-mönstret): admitted=40 (exakt 40; testet fångar per-pod-buggen).

(Verifierat i Task 8: services/bff/.venv/bin/pytest tests/load/test_global_rate_limit_proof.py → 2 passed; assertionerna är exakta likheter (20 resp. 40), inte toleranser.)

4. Provider-limits (llm-gateway)

  • Delad per-nyckelgräns sedan D-175 (P17): services/llm-gateway/gateway/ratelimit.py räknar med limits (glidande fönster) i samma Valkey som de fyra tjänsterna ovan. Fram till dess var det en token-bucket per pod, en avvägning från services/llm-gateway/docs/superpowers/specs/2026-04-13-llm-gateway-subproject-1-design.md §2.5 som D-175 ersätter. Är storen nere blir svaret 503 med Retry-After (fail-closed). Beviset är services/llm-gateway/tests/integration/test_ratelimit_shared_store.py: två begränsare mot en Valkey släpper igenom exakt rpm, och två på memory:// släpper igenom 2 × rpm.
  • Konfigurerad kapacitet: services/llm-gateway/config/routing.toml [rate_limit] default_rpm = 360, alltså 360 anrop per minut och API-nyckel för hela plattformen. bff har en nyckel per tenant, så det är tenantens tak. Värdet var 60 per pod, alltså 180–360 vid 3–6 repliker. 360 behåller taket vid full autoskalning.
  • Uppströms providers (OpenAI, Anthropic, Google, Mistral, Berget — services/llm-gateway/config/routing.toml:4-27): respektive leverantörs egna rate limits är INTE modellerade i denna plattform — de varierar per avtal/tier och ligger utanför repots kontroll. Ett verkligt 10k-lasttest mot RIKTIGA providers skulle träffa detta tak, inte något denna plattform kan konfigurera bort — därför kör #T6:s lasttest (Task 6, k6-profilen) medvetet mot en mock-provider, inte de faktiska leverantörerna.

5. Andra accepterade avvägningar (dokumenterade, inte åtgärdade av #T6)

  • bff:s signerade klient-side sessionskaka (services/bff/app/session.py, itsdangerous.URLSafeTimedSerializer) är korrekt konstruerad för horisontell skalning — inget server-side state att shard:a. Känd, dokumenterad begränsning (services/bff/README.md:257): “Force-logout impossible: Session är en signerad cookie utan server-side store … en stulen kopia är giltig till TTL (max 24 h). Mitigering kräver server-side session-store (t.ex. Redis).” Accepterad avvägning, inte en #T6-blockerare.
  • auth-apis OAuth-tabeller har ett tak sedan D-166. auth.oauth_codes och auth.oauth_refresh_tokens växte utan gräns (#144). Dygnsronden (app/retention_sweep.py, CronJob 37 3 * * *) raderar koder 24 h efter expires_at och refresh-rader när kedjans chain_expires_at passerat. Stående volym är därför ungefär ett dygns koder plus alla rader i levande kedjor, högst 30 dygn bakåt (oauth_refresh_chain_max_seconds). En klient som används hela tiden roterar var femte minut, alltså upp till 288 rader per dygn och kedja. Raderingen gör en sekventiell skanning per tabell och natt; det finns inget index på expires_at eller chain_expires_at. Ett sådant läggs till om ronden börjar närma sig activeDeadlineSeconds.

6. Lasttestets resultat (#T6 kriterium 1)

Task 6 byggde k6-lasttestharnesset (tests/load/k6/chat_profile.js, tests/load/k6/setup_site.py, tests/load/docker-compose.load.yml, .github/workflows/load-gate.yml; se tests/load/README.md för körning). Profilen driver BFF:s publika anonyma chattväg (POST /public/session → POST /public/chat) genom hela BFF → llm-gateway → provider-kedjan, med trösklarna ttft (res.timings.waiting som TTFT-proxy) p(95) < 2000 ms och http_req_failed rate < 0.01 — samma SLO som driftlagrets #T6-krav (TTFT p95 ≤ 2 s, ingen felbudgetbrott). Skriptet är identiskt mellan den lokala CI-röken (TARGET_VUS=300) och den faktiska 10k-körningen — bara miljövariabler skiljer.

Pending staging — INGA siffror uppfinns här. Den faktiska target_vus=10000-körningen kräver en driftsatt miljö med Wave 2:s HPA- och Valkey-ändringar (staging eller motsvarande skalad miljö) — det finns ingen sådan miljö tillgänglig från denna dokumentations-task. Detta är den enda delen av #T6 som har ett genuint externt beroende (ett driftsatt kluster) utöver detta repo.

<FYLL I VID KÖRNING (Task 8, eller en framtida runbook-körning): TARGET_VUS, observerad TTFT p95, felbudget, och mot vilken miljö (lokal docker-compose-smoke vs. den faktiska 10k-körningen mot staging/skalad miljö). Fram tills dess räknas #T6:s kriterium 1 som ÖPPET.>

Sammanfattning (#T6:s tre kriterier)

  • Kriterium 1 (lasttest 10k @ TTFT p95 ≤ 2 s, ingen felbudgetbrott): DELVIS — k6-harnesset byggt och mekaniskt verifierat (Task 6: parsar, trösklar kopplade, fail-closed mot otillgängligt mål), men den faktiska 10k-körningen väntar på en driftsatt staging-miljö med Wave 2:s HPA + Valkey. Enda #T6-delen med ett externt beroende utanför repot.
  • Kriterium 2 (rate limiting globalt över pods): UPPFYLLT — bevis i repot (tests/load/test_global_rate_limit_proof.py: delad Valkey → exakt 20, negativ kontroll memory:// → exakt 40).
  • Kriterium 3 (kapacitetsmodell i docs): UPPFYLLT — detta dokument.

Tillagd fas Wave 2, D-53 (2026-07-23).


7. Hot path-nollmätning — llm-gateway (#330)

Detta är INTE #T6 kriterium 1. Kriterium 1 mäter TTFT p95 vid 10k samtidiga användare mot en driftsatt miljö och står kvar som ÖPPET i §6. Det här är en annan fråga, ställd i #330: språkvalet (Rust/TS) kom upp utan att vi visste hur vår EGEN overhead fördelas — är den runtime, eller är den våra egna åtaganden? Nollmätningen finns för att språkbytet (oåterkalleligt, D-110) ska få ett underlag som är vårt eget och inte en siffra lånad från någon annans benchmark.

Så skrivs en prestandasiffra här — tre regler, var och en betald med ett misstag

En prestandasiffra är inte ett tal. Den är ett tal plus sina villkor, och villkoren måste resa MED talet — inte stå i ett stycke ovanför eller i en brasklapp nedanför. Ett tal som citeras utan sina villkor blir fel någon annanstans, och det har hänt tre gånger i den här serien:

  1. Miljön står i siffran. 793 ms mättes på en utvecklingsmaskin där OPA kör under qemu-emulering och kostar tio gånger sitt riktiga pris. Miljön stod i metodstycket ovanför tabellen — och siffran citerades ändå som en produktionssiffra i en efterföljande diskussion, där 800 − 200 = 600 blandade ett laptop-tal med produktionsmätningar. Tabellhuvudena nedan bär därför sin miljö.
  2. Indata står i siffran. Nollmätningens första omgång använde strängen "Hej" — tre tecken — och drog slutsatsen att policy är den största posten. Vid realistisk samtalslängd vänder rangordningen (D-138): pii växer med historiken, policy är konstant. Slutsatsen var inte fel av slarv utan av indata, och ett tal utan sin indata hade skickat nästa paket mot fel flaskhals.
  3. Regimen står i siffran. Allt nedan är sekventiell latens vid låg samtidighet. Den säger ingenting om kapacitet. Samma rigg föll med 92 % fel vid RATE=10 (se förbehåll 3), och den gränsen är inte ommätt efter någon av seriens förbättringar. Hot path-LATENSEN är utredd; hot path-KAPACITETEN är det inte.

Metod

tests/load/k6/gateway_hotpath.js, sex mätpunkter på befintliga HTTP-ytor mot den lokala docker-compose-stacken med mock-provider. Ingen ny tjänstkod, ingen flagga som stänger av policy, pii eller kedjeskrivning — golvet mäts med /health i stället. Körningen 2026-09-03, RATE=2 DURATION=30s, ~60 anrop per punkt, noll fel (http_req_failed = 0.00 %).

Miljön är en utvecklingsmaskin (Colima på Apple silicon), llm-gateway kapad till 2 vCPU av tests/load/docker-compose.load.yml, samtliga tjänster enkel uvicorn-worker.

Resultat

Mätpunkt — lokal rigg, Colima/Apple silicon, OPA under qemu, 2 rps sekventiellt, prompten "Hej" Vad den bär p50 p95
POST :19999/v1/chat/completions mock-provider direkt, UTAN gateway 2,25 ms 7,92 ms
GET :18082/health gatewayens uvicorn/FastAPI-golv 2,96 ms 5,04 ms
GET :18082/v1/models + HMAC-auth (DB-slagning) + katalogupplösning 4,57 ms 8,45 ms
POST :18082/v1/chat/completions + pii, decide, provider, kedja, usage 792,64 ms 875,60 ms
POST :18081/v1/decide (ETT anrop) policy-engine, nedbrytning 402,55 ms 469,55 ms
POST :18086/v1/analyze (ETT anrop) pii-api, nedbrytning 12,56 ms 17,14 ms

De två sista raderna finns för att chat-punkten annars levererar “~790 ms oförklarat”, vilket inte besvarar frågan. De mäter tjänsternas egna publika ytor — ingen instrumentering, inga interna httpx-klienter rörda (den nedbrytningen står kvar som namngiven uppföljning i docs/atlas.md).

Vad deltat säger

  • Ramverksgolvet är ~3 ms och det mesta av det är klient plus loopback: en minimal FastAPI-app på samma väg (mock-provider) ligger på 2,25 ms. Gatewayens hela middleware- och ruttstack kostar alltså ~0,7 ms p50 utöver en tom FastAPI-app.
  • Auth plus katalog kostar ~1,6 ms p50 (4,57 − 2,96), inklusive HMAC-verifiering och DB-slagningen av nyckeln.
  • Chat-vägen gör FYRA nedströmsanrop plus providern: två decide (in-beslut + output_check) och två pii-skanningar (in + ut). Det andra decide-anropet är villkorat — output_check_required är falskt när assistentens outflow är off (policy-engine/app/services/decide.py:146), så fyra är det normala fallet och inte det enda.
  • Nedbrytningen går inte ihop, och det är ett resultat i sig. 2 × 402 + 2 × 12,6 + 2,25 = 832 ms mot en uppmätt chat-p50 på 793 ms — summan överskjuter med ~5 %. Medianer adderas inte, scenarierna kördes 35 s isär, och k6:s decideOne skickar inte pii-signalerna som gatewayen skickar. Gatewayens egen andel är därför obestämd ur den här datan, inte “under brusgolvet” — allt vi kan säga är att den ryms i intervallet 0–40 ms.

Svaret på frågan: overheaden är våra egna åtaganden, inte runtime. Den enskilt största posten är att POST /v1/decide anropas två gånger per chattanrop.

Var noga med riktningen på gatewayens tal. models-punktens 4,6 ms är en övre gräns för auth plus katalog plus ramverk, inte för allt gatewayen gör: chattvägen har dessutom JSON/pydantic på en riktig body, två httpx-anrop och två DB-skrivningar (kedja + usage) som INTE isolerats. ~3–5 ms är alltså en UNDRE gräns för vad ett språkbyte skulle angripa, med en oisolerad rest ovanpå. Storleksordningen mot 790 ms håller vida, men talet får inte citeras som ett tak.

Vad mätningen INTE säger — fem förbehåll

  1. De 402 ms per decide är en riggartefakt, inte en produktsiffra. policy-engine exec:ar en amd64-statisk OPA-binär per decide, och under qemu-emulering på Apple silicon kostar det hundratals millisekunder (redan dokumenterat i tests/e2e/docker-compose.e2e.yml:s kommentar till POLICY_ENGINE_TIMEOUT_MS). Antalet rundturer är däremot arkitektur, inte artefakt — fem nätverkshopp per chattanrop står kvar på native hårdvara.
  2. Mätningen gjordes vid 2 rps, alltså låg samtidighet. LiteLLM:s Rust-siffra (p99 0,7 ms mot Python-proxyns 257,7 ms, docs.litellm.ai) är p99 added latency mot en lokal mock — bloggen anger n=5000 och “single-host, per-scenario runs without repeated-trial error bars”, och vilken samtidighet overhead-panelen kördes vid framgår inte. Nollmätningen begränsar per-anrops-andelen; den motbevisar inte en svansvinst vid 10k användare. Bloggens egen slutsats pekar åt vårt håll: “For a single chat turn, gateway overhead is noise next to model latency and none of this should change your decision. Where it matters is high request rate against fast responses (embeddings, classification, guardrails) and agentic loops.” Deras Python-tal gäller dessutom LiteLLM-proxyn, en betydligt tyngre kodbas än vår FastAPI-app.
  3. Riggen mättas mellan 2 och 10 rps. Vid RATE=10 föll chat-punkten till p50 5,0 s / p95 9,7 s med 92 % fel — 403 output_blocked (output_check_unavailable) och 503. Fail-closed fungerade som avsett; policy-engine var flaskhalsen. Det är en gräns för den lokala riggen under emulering, inte för produkten.
  4. Testlasten är strängen "Hej". Gatewayen skickar hela meddelandehistoriken till pii-api in och hela svaret ut, och Presidio/spaCy-NER skalar med textlängd — pii_api_timeout_ms defaultar till 1500 ms just med motiveringen att NER på långa texter behöver mer än decide-anropet (gateway/config.py). Slutsatsen åtaganden, inte språk står oavsett, men rangordningen “decide är största posten” är bara belagd för en tre tecken lång prompt. För en lång konversation är pii-kostnaden okänd och kan mycket väl passera decide. Det är den rangordningen som styr vilket vägval som ställs härnäst, så den behöver mätas med 1k/5k/20k tecken innan den bär ett beslut.
  5. Minne och pods per krona är inte mätt alls. LiteLLM:s andra axel är fotavtryck (21,8 MB mot Python-proxyns 329,5 MB), och den avgör hur många poddar man kör och hur nära OOM var och en ligger. I stacken-digitalist på customer-prod ligger llm-gateway på 69–71 MiB RSS mot ett tak på 256Mi och 2 millicore av 500m — men det är en tomgående miljö. Under last är siffran okänd, och den här mätningen säger ingenting om den.

Vad som skulle avgöra Rust-frågan på riktigt

Förbehåll 2 och 5 lämnar två axlar obesvarade, och #T6:s kriterium 1 stänger ingen av dem — det mäter TTFT p95 end-to-end och isolerar därför inte gatewayens EGEN svans, vilket är precis det frågan hänger på. Scenariot där slutsatsen ändå vore fel: en Python-worker som håller tusentals öppna SSE-strömmar (sekunder per anrop mot en riktig provider), där minnesväxt per anslutning, GC-pauser och event-loop-kö ger en p99-svans och tvingar fram fler poddar. Ingenting av det syns i en p50 vid 2 rps, och /health-svepet nedan bevisar det inte heller — /health rör varken JSON-parsning, pydantic, httpx eller DB.

Mätningen som skulle avgöra det: gatewayen med policy-engine och pii-api ersatta av stubbar på fast latens, strömmande svar på ett par sekunder, samtidighet 100/1 000/5 000 per podd, och p99 added latency, RSS och rps per vCPU som utfall. Det är samma form som AIGatewayBench:s concurrent_agents-scenario, alltså steg 2 i minimalitetsordningen (D-120) — återanvänd deras harness hellre än att skriva en egen. Inte gjort här.

Sidoobservation om samtidighet, med sin egen brasklapp

Gatewayens egna ytor (/health, som inte rör policy-engine) kördes med en engångsprob upp till 500 rps mot en enkel worker. Tre rena upprepningar gav p99 4,8 / 6,8 / 11,5 ms — ingen knä. Den FÖRSTA svepningen visade p99 457 ms vid samma takt, men den kördes med samtidig docker stats-sampling på samma värd, och siffran gick inte att reproducera utan den. Bruset, inte gatewayen, var fyndet (verifiering-skillens fälla 6: mät, härled inte). En stabil svansmätning kräver en tyst, dedikerad miljö och hör därför till #T6:s kriterium 1 — inte hit.

Uppföljningen: samma fråga mätt på riktig hårdvara

Förbehåll 1 ovan sa att de 400 ms per decide var qemu-emulering och inte en produktsiffra. En mätning på riktig hårdvara är nu gjord, 2026-09-03, i en körande policy-engine-pod i stacken-digitalist på customer-prod (amd64-noder, alltså ingen emulering). Pod:en var i praktiken tomgående (3 millicore av limits.cpu: 500m, noll decide i loggen på sju dygn), så mätningen konkurrerade inte med riktig trafik.

Läs noga vad som mättes. Ingen /v1/decide anropades och ingen beslutspost skrevs — opa eval kördes direkt i pod:en med tjänstens egen policy-bundle och samma argument som app/services/opa_runtime.py bygger. Det är OPA-evalueringen INUTI ett beslut, inte ett helt beslut. Ett riktigt /v1/decide bär dessutom auth, två DB-läsningar (assistent, protection), en DB-insert av beslutsposten och HTTP-hoppet från gatewayen, och spawnar OPA från uvicorn-processen i stället för från ett litet skal. Siffrorna nedan är alltså ett GOLV för vad ett policybeslut kostar. Förbehåll 1 är därmed inte avlyft utan utbytt: emuleringen är borta, men avståndet till ett helt beslut är kvar och omätt. Metod: n=25 back-to-back respektive n=12 med 1–2 s paus, date +%s%N runt varje anrop, tre varmkörningar först.

Vad som mäts p50 min max
starta OPA-processen över huvud taget (opa version) 23 ms 15 ms 25 ms
+ ladda en TOM policy 28 ms 24 ms 33 ms
+ ladda VÅRA nio .rego och evaluera 45 ms 37 ms 99 ms
samma sak, anrop efter anrop utan paus 99 ms 19 ms 157 ms

Bara OPA-evalueringen kostar 45–99 ms på produktionshårdvara, och 23 av dem är att starta ett program. Ytterligare ~17 ms är att läsa in de nio .rego-filerna — varje gång, för varje beslut. Själva evalueringen är resten. policy-engine kör alltså OPA som ett engångskommando (asyncio.create_subprocess_exec i opa_runtime.py), inte som den långlivade server OPA normalt driftas som.

Chattvägen gör två sådana anrop, så minst ~90–200 ms av varje chattanrop är processtart och inläsning av policyfiler — före allt annat ett helt beslut kostar. Gatewayens egen andel är INTE mätt i prod; de 4 ms i tabellen ovan kommer från Colima-riggen, där gatewayen hade 2 vCPU utan strypning, medan prod-podden har samma 500m-tak som gav OPA 45 → 99 ms. Storleksordningen från emuleringsmätningen håller ändå: det är åtagandena och hur de utförs, inte språket.

Två saker till som föll ur mätningen:

  1. CPU-strypningen är verklig och verifierad. cpu.stat i pod:ens cgroup räknade upp nr_throttled 76 → 88 under mätningen. Vid limits.cpu: 500m får containern 50 ms CPU per 100 ms-period, medan en OPA-start vill ha en hel kärna en kort stund. Det är därför samma operation ligger på 45 ms utspridd och 99 ms i följd. Taket gör inte bara evalueringen långsammare, det gör den ojämn — och en ojämn latens är det som träffar tidsgränsen nedan.
  2. Marginalen till fail-closed är tunn. OpaRuntime.eval_timeout är 0.2 s och en timeout ger _FAIL_CLOSED, alltså deny. Högsta uppmätta evaluering var 157 ms. Miljön är i dag tomgående, så det är latent och inte aktivt — men under last, där strypningen biter hårdast, är avståndet mellan 157 och 200 ms det som står mellan en användare och ett nekat svar.

Ingen åtgärd är beslutad här. Att driva OPA som en långlivad server i stället för ett engångskommando är en ändring av hur policybeslut fattas på datapath:en, alltså oåterkalleligt (D-110) och ett eget vägval. Den här posten är mätningen, inte valet.

CPU-taket: åtgärden är utrullad och verifierad

#334 höjde policy-engines limits.cpu från 500m till 1000m. Plattformssidan rullade ut det 2026-09-03 som en tidsbegränsad överstyrning i kundinstansens ApplicationSet (plattformen#190) i stället för att flytta pinnarna — alla tretton står kvar på samma commit, så invarianten i image-pinning.md är obruten. Skälet de angav var inte tjänstkoden utan NetworkPolicy-mallen, vars selektorer väljer på ANDRA tjänsters poddetiketter.

Samma mätning som ovan, om körd 2026-09-04 i den nya podden (n=25 i följd):

p50 p90 max
före, 500m 99 ms 111 ms 157 ms
efter, 1000m 47 ms 59 ms 62 ms

Halverat, och svansen är det som förbättras mest. Marginalen till eval_timeouts 200 ms — som ger fail-closed deny — gick från 22 % till 69 %. Det var den egentliga risken, inte millisekunderna.

Kvar står att kostnaden till största delen fortfarande är processtart och inläsning av policyfilerna. Taket gjorde det som var kontention; resten är formen på arbetet.

Ommätning efter CPU-taket: vad ett policybeslut kostar nu

#334 höjde policy-engines limits.cpu till 1000m och plattformssidan rullade ut det 2026-09-03. Ommätt 2026-09-04 i den nya podden — helt läsande, inget /v1/decide anropades och ingen beslutspost skrevs. opa eval kördes direkt i podden som förut; de två DB-läsningarna tidsattes genom tjänstens EGNA session_scope inifrån podden, så pool_pre_ping, BEGIN och COMMIT ingår precis som i skarp drift.

Del av ett /v1/decide — produktionspod, amd64, sekventiellt vid 500m vid 1000m
opa eval (p50) 99 ms 43 ms
opa eval (max) 157 ms 61 ms
assistentläsning (SELECT på id, p50) — 6,4 ms
protection-defaults (p50) — 6,2 ms
beslutspostens INSERT ej mätt (skriver) ej mätt (skriver)
HTTP-hoppet från gatewayen ej mätt ej mätt

Taket halverade den dominerande delen. De omätta posterna är desamma före och efter, så skillnaden är helt fångad även om totalen inte är det: −56 ms per decide, −112 ms per chattanrop (två decide per meddelande).

Svansen förbättrades mer än medianen, vilket var den egentliga risken: OPA_EVAL_TIMEOUT_SECONDS är 0,2 s och en timeout ger fail-closed deny. Marginalen gick från 22 % till 69 %. Mätningen gjordes dessutom om efter en omdeploy — överstyrningen håller.

En ny observation ur samma mätning: de två DB-läsningarna kostar ~6,3 ms var. Det är fyra rundturer styck (pool_pre_pings SELECT 1, BEGIN, SELECT, COMMIT) mot en Postgres i samma kluster. Vid 500m var de 11 % av de mätta delarna, vid 1000m är de 23 % — inte för att de blev långsammare utan för att OPA blev snabbare. Att ta bort pool_pre_ping på chattvägen sparar en rundtur per läsning (~1,5 ms), alltså ~6 ms per chattanrop, och är en negativ diff. Om de också var långsammare vid 500m vet vi inte — de mättes bara efter ändringen.

Varför detta inte gick att mäta lokalt. tests/load/docker-compose.load.yml sätter resursgränser på bff och llm-gateway, inte på policy-engine. Den lokala riggen har alltså aldrig haft taket som ändrades, och en ny körning av gateway_hotpath.js hade visat samma siffror som förut. Riggens OPA är dessutom qemu-emulerad och kostar hundratals ms, vilket dränker en skillnad på 56 ms. Frågan gick bara att besvara i produktion.

Rangordningen, mätt på båda sidor — och den vänder med samtalslängden

§7:s första mätning använde strängen "Hej" och drog slutsatsen att decide är den största posten. Det gäller bara för korta prompter. Förbehåll 4 sa att rangordningen behövde mätas med riktig textlängd innan den bar ett beslut. Det är nu gjort, 2026-09-03, på båda sidor.

pii-api, mätt i en körande pod i stacken-digitalist på customer-prod (amd64, limits.cpu: 1, tomgående). Anropet gick mot 127.0.0.1:8080/v1/analyze inifrån poden med tjänstens eget SERVICE_TOKEN expanderat i podens skal — tokenvärdet lämnade aldrig containern. Syntetisk svensk förvaltningsprosa, level=high, n=15 per längd efter tre varmkörningar. Endpointen är tillståndslös och skriver ingenting.

Historikens längd — pii-api i produktionspod, amd64, sekventiellt p50 p95
100 tecken 11,8 ms 21,4 ms
1 000 tecken 29,6 ms 42,6 ms
5 000 tecken 84,3 ms 99,0 ms
20 000 tecken 332,5 ms 346,5 ms

Kostnaden är linjär: ~10 ms golv plus ~16 ms per 1 000 tecken. Samma form mättes i den lokala riggen (~12 ms + ~10 ms per 1 000 tecken), så lutningen är en egenskap hos Presidio/spaCy och inte hos hårdvaran.

Jämför med policy på samma kluster: en OPA-evaluering kostar 45–99 ms och chattvägen gör två, alltså 90–200 ms — konstant, oberoende av promptens längd. PII körs också två gånger (in på hela historiken, ut på svaret).

Historik pii in + ut 2 × OPA-eval störst
1 000 tecken ~45 ms 90–200 ms policy, 2–4×
5 000 tecken ~105 ms 90–200 ms jämnt
20 000 tecken ~350 ms 90–200 ms pii, 2–4×

Brytpunkten ligger vid ungefär 5 000–10 000 tecken sammanlagd historik, alltså efter en handfull turer i ett vanligt samtal. Det är inte ett extremfall.

Den strukturella observationen är viktigare än brytpunkten. decide är konstant; pii växer med HELA historiken och körs om från noll vid varje tur. Tur tio skannar om de nio turer som redan skannats nio gånger. Kostnaden över ett samtal växer alltså kvadratiskt, medan policyns växer linjärt med antalet turer. Ju längre samtalet blir, desto mer dominerar pii — och det finns ingen längd där det vänder tillbaka.

Vad som INTE är utrett: om per-meddelande-resultat kan återanvändas mellan turer. Maskeringen ser ut att vara en ren funktion av (text, level), men en cache skulle bära användarinnehåll, och GDPR-regeln säger metadata-only utan opt-in och TTL. Det är en egen analys, inte en slutsats härifrån.

DB-skrivningarna är försumbara — mätt, inte antaget

Samma session, gatewayens egen kod inifrån containern mot stackens Postgres, n=300 per rad:

Skrivning — lokal rigg, mot stackens Postgres p50 p95 p99
auditkedja (append_model_call, FOR UPDATE + INSERT i en transaktion) 1,44 ms 3,59 ms 6,10 ms
usage (record_usage, INSERT) 0,31 ms 0,48 ms 0,57 ms
båda i följd 1,75 ms 2,13 ms 3,52 ms
kedjan, 10 samtidiga skrivare mot SAMMA tenant 8,50 ms 17,86 ms 107,4 ms

1,75 ms tillsammans, 0,2 % av chattanropet. Radlåset serialiserar per tenant men först vid ~830–940 appends/s per tenantkedja — en skalfråga för #T6, inte en latensfråga här.

Gatewayens oförklarade rest, med textlängden varierad

Chattpunkten mättes vid 100/1 000/5 000/20 000 tecken (882 / 895 / 897 / 1 070 ms p50). När allt mätt dras bort återstår 20–55 ms, 2–6 % av chat-p50 — och resten VÄXER INTE med textlängden. Det utesluter JSON/pydantic på en stor body som förklaring och pekar mot de extra httpx-rundturerna. Slutsatsen att overheaden är åtaganden och inte runtime står därmed belagd även vid 20 000 tecken, inte bara för tre.

Kapaciteten är fortfarande omätt — seriens öppna ände

Latensen är utredd, kapaciteten inte. Varje tal i §7 är sekventiellt vid låg samtidighet. Den enda kapacitetsobservationen är förbehåll 3: samma rigg föll med 92 % fel vid RATE=10 (403 output_blocked ur output_check_unavailable, plus 503) med policy-engine som flaskhals.

Sedan dess har den flaskhalsen angripits två gånger — CPU-taket (#334, kontentionen borta) och OPA som långlivad process (D-142, 46 ms → 1,7 ms per beslut, alltså också i den emulerade riggen där processtarten var som dyrast). Gränsen har med all sannolikhet flyttat sig avsevärt. Ingen har mätt vart.

Nästa mätning är därför en RATE-stege, inte en ny latensmätning: samma rigg, samma skript, RATE 2 → 5 → 10 → 20, och frågan var faller chat-punkten nu? Talet före är känt (10 rps), så det blir ett före/efter på samma rigg i stället för ett nytt absolut påstående. Det är också den enda av seriens frågor som pekar mot #T6 kriterium 1, som står lika obelagt som före serien: docs/capacity-model.md §6 kräver 10k samtidiga och en driftsatt miljö, och ingen av förbättringarna här har rört det kravet.

Fyndet är Cenk Bisgens i utvärderingen av D-137–D-142 (2026-09-04), och det är seriens skarpaste: hot pathens latens är löst, dess kapacitet är okänd.

Kapacitetsstegen kördes — och riggen kunde inte svara, men produktionen kunde

Utfallet är två saker: ett svar, och en gräns hos vårt eget verktyg.

Latensvinsten är enorm på samma rigg. RATE=10, samma skript, samma maskin:

chat-punkten — lokal rigg, RATE=10 sekventiell ankomst före serien efter
p50 5 010 ms 20,75 ms
p95 9 710 ms 39,29 ms

Pii-cachen syns direkt i anropsräkningen: 5 anrop till pii-api för 301 chattar (samma prompt, alltså träff varje gång efter den första).

Men 286 av 301 chattar nekades, och orsaken var riggen. opa_unreachable — OPA-servern var död. Reproducerat med felutskriften sparad: fatal error: found bad pointer in Go heap, alltså korruption i Go:s skräpsamlare. Det är qemu-emuleringen, inte produkten. Samma hammare (200 samtidiga × 5 omgångar) på native amd64 i en tillfällig prod-pod: 1 000 anrop, noll fel, tom felutskrift, servern lever. Under emulering dör den i omgång två.

Slutsats om riggen: tests/load/ kan inte längre svara på kapacitetsfrågor för någon väg som går genom OPA. Latens går fortfarande att mäta där (en åt gången överlever emuleringen); kapacitet gör det inte. Det är en gräns hos verktyget, och den bör stå här så nästa läsare inte spenderar en timme på att återupptäcka den.

Policylagrets kapacitet, mätt på native hårdvara

Tillfällig pod från imagen 20fa6167, samma limits.cpu: 1000m som drift, OPA-servern hamrad direkt (alltså inte ett helt /v1/decide — auth, två DB-läsningar och beslutspostens insert tillkommer, så talen är ett TAK för policylagret och inte för tjänsten):

samtidighet — prod-pod, amd64, 1000m, enbart OPA-servern p50 p95 genomströmning
1 1,50 ms 1,91 ms 632 beslut/s
8 6,92 ms 70,41 ms 417 beslut/s
32 83,45 ms 106,75 ms 460 beslut/s
64 112,95 ms 264,64 ms 450 beslut/s

Noll fel på samtliga nivåer. Genomströmningen mättas kring ~450 beslut/s per pod; därefter växer latensen linjärt med kön, vilket är väntat och ofarligt — ända tills den möter tidsgränsen.

Och där finns den operativa siffran: OPA_EVAL_TIMEOUT_SECONDS är 0,2 s och en timeout ger fail-closed deny. Vid samtidighet 64 är p95 265 ms, alltså över gränsen. Podden börjar neka runt 64 samtidiga policybeslut, inte för att något är trasigt utan för att kön blir längre än budgeten. Chattvägen gör två beslut per meddelande, så det motsvarar grovt ~225 meddelanden/s per pod innan fail-closed börjar bita.

Det är seriens första kapacitetstal, och det ersätter inte #T6 kriterium 1: det mäter ett lager, inte kedjan, och inte mot 10k samtidiga användare i en driftsatt miljö.

Ommätt i drift — hela resan, i den körande tjänsten

D-142 rullades ut 2026-09-04 (plattformen#196, e61df363 → 20fa6167 på båda pinnarna). Ommätt i den körande pod:en, läsande, n=25:

policybeslutets tunga del — produktionspod, amd64, sekventiellt p50 max
opa eval per beslut, limits.cpu: 500m 99 ms 157 ms
opa eval per beslut, limits.cpu: 1000m (#334) 43 ms 61 ms
opa run --server, i drift (D-142) 1,32 ms 4,88 ms

75 gånger från utgångsläget, och det är inte längre en mätning i en tillfällig pod utan i tjänsten som faktiskt betjänar trafik.

Marginalen till fail-closed är det som förbättrades mest. OPA_EVAL_TIMEOUT_SECONDS är 0,2 s och en timeout ger deny. Högsta uppmätta gick från 157 ms (22 % marginal) till 4,88 ms (97,6 %). Det var den egentliga risken hela vägen, inte millisekunderna.

Poddarna ligger på 81–82 MiB mot tidigare ~71 — OPA-processen syns, och bekräftar varför minnestaket 256Mi → 384Mi måste följa med imagen.

En post som ingen mätning ovan kunde se

gateway/main.py skapar den provider-vända httpx.AsyncClient utan limits, så httpx default gäller: keepalive_expiry = 5.0 sekunder. Vid glesare trafik än så bygger varje chattanrop en ny TCP- och TLS-anslutning mot leverantören. Uppmätt handskakningstid (från en laptop i Sverige, n=5 per värd — INTE från Hetzner, så siffran är en storleksordning och inte en produktsiffra): api.openai.com 34 ms, api.anthropic.com 36 ms, models.think.evroc.com 43 ms, api.berget.ai 52 ms.

Ingen av mätningarna ovan kunde se det, eftersom mock-providern går på loopback utan TLS. Åtgärden är ett limits-argument på klienten och den är återkallelig.

Tillagd 2026-09-03, #330.