ADR-019: Per-environment App Service plan sizing, prd on P0v3#
Status#
Accepted (amended 2026-08-20 — see “Alert threshold adjustment” below)
Context#
On the B2 plan the App Service sat close to its memory ceiling while serving several analysis calls concurrently (evidence: the Azure portal memory graph attached to issue #204). The instance has not been sized since it was first provisioned, and the question was whether to buy headroom or to shrink the footprint.
What holds memory in this service:
The BGE-M3 ONNX embedder is baked into the image and, once loaded, stays resident for the process lifetime (ADR-014,
src/qfa/adapters/embedding.py).The per-request corpus plus per-batch tokeniser padding — one
session.run()per batch ofEMBEDDING_BATCH_SIZErecords (default 100).entrypoint.shexecsgunicorn … --worker-class asgiwith no-w, so there is exactly one worker and therefore one resident ONNX session per instance. Concurrency inside that worker is async, so concurrent calls share the model but each add their own corpus.
The relevant tiers:
SKU |
vCPU |
RAM |
|---|---|---|
|
2 |
3.5 GiB |
|
1 |
4 GiB |
|
2 |
8 GiB |
P0v4 was not offered in the environment’s region at decision time.
Decision#
Size the App Service plan per Terraform workspace: prd runs P0v3,
dev and staging stay on B2.
The values live in var.app_service_plan_sku_by_env (infra/variables.tf) and
are resolved by local.app_service_plan_sku, which lookups the current
workspace and falls back to B2 for any workspace not in the map — a new
environment can never be silently provisioned as Premium.
P1v3 is held as the documented escalation, not applied now.
Options Considered#
A. prd on P0v3, dev/staging on B2, P1v3 held in reserve (chosen)#
Pro: Buys headroom exactly where user load is, at the smallest Premium step; cost rises on one environment only.
Pro: Reversible in one word, and the next step up is already decided, so an alert firing turns into an edit rather than a fresh investigation.
Con: Trades vCPU for RAM (see Consequences) — the non-obvious cost.
Con: dev no longer mirrors prd sizing.
B. Stay on B2 and shrink the footprint (rejected)#
Lower EMBEDDING_BATCH_SIZE, cap request concurrency, or stop baking the model
into the image and load it on demand.
Pro: No cost increase.
Con: Each lever trades throughput or latency for headroom, and none of them moves the ceiling — the same graph reappears at higher load.
Con: On-demand model loading turns a one-off startup cost into a per-request one, against ADR-014.
C. Upgrade every environment uniformly (rejected)#
Pro: dev reproduces prd’s resource behaviour.
Con: dev and staging carry no user load — staging sees smoke tests only. Pure cost for headroom nothing will use.
D. Go straight to P1v3 (rejected)#
Pro: Fixes both axes at once — 2 vCPU and 8 GiB.
Con: Premature ahead of real production traffic. Size on measurements from actual users rather than on a pre-launch guess; the alerts below are the measurement.
Consequences#
prd RAM 3.5 → 4 GiB, but prd vCPU 2 → 1.
P0v3is the smallest Premium v3 tier and has fewer cores thanB2. Startup — Alembic migrations plus loading the embedding model — is slower on prd than on dev, and more likely to trip thehigh_cpualert.Higher run cost on prd only; dev and staging are unchanged, so applying this to those environments is a no-op.
dev no longer mirrors prd sizing, so a prd memory ceiling cannot be reproduced in dev.
Changing the tier restarts the app — App Service moves the site to new workers, so the container re-migrates and reloads the model. See the runbook in Operational how-tos.
Escalation trigger: if
high_memoryorhigh_cpukeeps firing on prd once real users are on it, moveprdtoP1v3(2 vCPU / 8 GiB) — a one-word edit tovar.app_service_plan_sku_by_envplus an apply. Do not raise the alert thresholds further instead.
When to revisit#
The escalation trigger above fires.
P0v4becomes available in the region — it is the same class at a newer hardware generation.The worker count stops being 1, or the model stops being baked into the image: both change the memory floor this sizing assumes.
Alert threshold adjustment (2026-08-20)#
high_cpu and high_memory (infra/observability.tf) are raised from 80% to
85%. 80% was set when the plan was still uniformly B2; it did not account for
prd’s startup CPU spike sitting closer to the ceiling on P0v3’s single vCPU
(see Consequences above), which made prd noisier without prd actually
approaching the resource exhaustion the alert exists to catch. 85% keeps the
same escalation trigger and the same “move to P1v3, don’t raise thresholds
again” rule — this is a one-time recalibration to the P0v3 sizing, not a
reopening of that rule.
Participants#
Marius, Daan