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):
- Höj
max_connectionspåplatform-pg- ochrag-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). - Sänk
max_overflow/pool_sizeexplicit per tjänst icreate_async_engine(...)-anropen (t.ex.pool_size=5, max_overflow=0→ 5/pod istället för 15/pod — sänkerplatform-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.pybevisar global verkställighet för slowapi-vägen. De två nya ASGI-lagren är en annan mekanism och är i dag bara prövade motmemory://i process. De delar Valkey via sammaRATE_LIMIT_STORAGE_URI, men ett exakt-N-bevis i samma form saknas — registrerat som D-76Kvarstår. | llm-gateway |RateLimiter(gateway/ratelimit.py),limitsmed 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.pyräknar medlimits(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ånservices/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 medRetry-After(fail-closed). Beviset ärservices/llm-gateway/tests/integration/test_ratelimit_shared_store.py: två begränsare mot en Valkey släpper igenom exaktrpm, och två påmemory://släpper igenom2 × 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_codesochauth.oauth_refresh_tokensväxte utan gräns (#144). Dygnsronden (app/retention_sweep.py, CronJob37 3 * * *) raderar koder 24 h efterexpires_atoch refresh-rader när kedjanschain_expires_atpasserat. 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_atellerchain_expires_at. Ett sådant läggs till om ronden börjar närma sigactiveDeadlineSeconds.
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 kontrollmemory://→ 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:
- Miljön står i siffran.
793 msmä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är800 − 200 = 600blandade ett laptop-tal med produktionsmätningar. Tabellhuvudena nedan bär därför sin miljö. - 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. - 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 assistentensoutflowäroff(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
decideOneskickar 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
- 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 itests/e2e/docker-compose.e2e.yml:s kommentar tillPOLICY_ENGINE_TIMEOUT_MS). Antalet rundturer är däremot arkitektur, inte artefakt — fem nätverkshopp per chattanrop står kvar på native hårdvara. - Mätningen gjordes vid 2 rps, alltså låg samtidighet. LiteLLM:s Rust-siffra (
p99 0,7 msmot Python-proxyns257,7 ms, docs.litellm.ai) är p99 added latency mot en lokal mock — bloggen angern=5000och “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. - Riggen mättas mellan 2 och 10 rps. Vid
RATE=10föll chat-punkten till p50 5,0 s / p95 9,7 s med 92 % fel —403 output_blocked(output_check_unavailable) och503. 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. - 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_msdefaultar 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. - Minne och pods per krona är inte mätt alls. LiteLLM:s andra axel är fotavtryck
(
21,8 MBmot Python-proxyns329,5 MB), och den avgör hur många poddar man kör och hur nära OOM var och en ligger. Istacken-digitalistpåcustomer-prodligger llm-gateway på 69–71 MiB RSS mot ett tak på 256Mi och 2 millicore av500m— 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:
- CPU-strypningen är verklig och verifierad.
cpu.stati pod:ens cgroup räknade uppnr_throttled76 → 88 under mätningen. Vidlimits.cpu: 500mfå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. - Marginalen till fail-closed är tunn.
OpaRuntime.eval_timeoutär0.2s 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.