Skip to content

Commit 041a882

Browse files
anth-volkclaude
andcommitted
Apply self-review findings: drop unqualified recycling, fix docs drift
Worker recycling (--max-requests) is removed rather than retuned: review analysis showed the 500-request budget recycles faster than the ~170s re-import at peak (the counter advances on cheap requests too), jitter 100 cannot decorrelate two workers, a mid-life re-import runs without cpu-boost and CPU-throttled at idle, and the qualifying soak never executed a recycle. The ops doc now records why it needs its own qualification. Also: pin assertions get boundary anchors; the ops doc's source-of-truth section acknowledges push.yml job pins; stale cold-start figures replaced with measured values; the changelog credits staging's shape change; the min-instances runbook gains WHEN + verification; the duplicated rationale comments collapse to pointers; load-generator cleanup (single exception path, Counter, always/never cache-bust, dead fields removed). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e2bc084 commit 041a882

7 files changed

Lines changed: 77 additions & 88 deletions

File tree

.github/scripts/cloud_run_env.sh

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,8 @@ cloud_run_set_defaults() {
1515
CLOUD_RUN_TIMEOUT="${CLOUD_RUN_TIMEOUT:-300}"
1616
CLOUD_RUN_MIN_INSTANCES="${CLOUD_RUN_MIN_INSTANCES:-0}"
1717
CLOUD_RUN_MAX_INSTANCES="${CLOUD_RUN_MAX_INSTANCES:-1}"
18-
# Runtime shape from the Stage 2 capacity qualification
19-
# (docs/migration/history/pr4-stage2-runtime-timing.md). Both values are
20-
# pinned explicitly on every deploy: gcloud inherits unspecified template
21-
# fields, and `--concurrency default` resolves to the platform default
22-
# (640), not the historical 80.
18+
# Stage 2-qualified runtime shape, pinned explicitly on every deploy —
19+
# rationale in docs/migration/cloud-run-operations.md ("Runtime shape").
2320
CLOUD_RUN_CONCURRENCY="${CLOUD_RUN_CONCURRENCY:-4}"
2421
CLOUD_RUN_WEB_CONCURRENCY="${CLOUD_RUN_WEB_CONCURRENCY:-2}"
2522
CLOUD_RUN_PORT="${CLOUD_RUN_PORT:-8080}"

.github/workflows/push.yml

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -542,11 +542,9 @@ jobs:
542542
environment: production
543543
env:
544544
CLOUD_RUN_SERVICE: policyengine-api
545-
# Sized by the Stage 2 capacity qualification: peak real traffic ~11
546-
# RPS (mostly cached/light); one instance handles ~1-2 concurrent
547-
# uncached calculates. Revision-level min-instances stays 0 — warm
548-
# capacity is a SERVICE-level setting (revision-level would keep a
549-
# warm 16Gi instance per accumulated stage3-* tag).
545+
# Sized by the Stage 2 qualification — rationale and numbers in
546+
# docs/migration/cloud-run-operations.md ("Runtime shape and scaling").
547+
# Never pin a min-instances value here: warm capacity is service-level.
550548
CLOUD_RUN_MAX_INSTANCES: "4"
551549
permissions:
552550
contents: read
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
Configure the production Cloud Run runtime from the Stage 2 capacity qualification: two gunicorn workers, request concurrency 4, production max-instances 4, staging pinned to scale-to-zero, and worker recycling to bound memory growth.
1+
Configure both Cloud Run services' runtime from the Stage 2 capacity qualificationtwo gunicorn workers and request concurrency 4 on staging and production alike — plus production max-instances 4 and explicit staging scale-to-zero pins.

docs/migration/cloud-run-operations.md

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,12 @@ edited in place whenever behavior changes. Per-stage records (bootstrap commands
55
exit-gate evidence, one-off measurements) are append-only documents under `history/` and
66
are never updated to match later reality.
77

8-
Configuration **values** (CPU, memory, scaling, secrets, service names) live in
9-
`.github/scripts/cloud_run_env.sh` and the deploy scripts — they are the source of truth
10-
and are intentionally not duplicated here. This document explains the **semantics**.
8+
Configuration **values** live in two layers, both source of truth for their scope:
9+
`.github/scripts/cloud_run_env.sh` holds the defaults (CPU, memory, runtime shape,
10+
secrets, service names), and **per-job `env:` pins in `.github/workflows/push.yml`
11+
override them per track** (e.g. production `CLOUD_RUN_MAX_INSTANCES`). To change what a
12+
track deploys, edit the job pin, not just the default — job pins win. Values are
13+
intentionally not duplicated here; this document explains the **semantics**.
1114

1215
## Topology
1316

@@ -32,21 +35,24 @@ and are intentionally not duplicated here. This document explains the **semantic
3235

3336
## Startup behavior
3437

35-
The app import is slow (~230s cold: `policyengine_api/country.py` instantiates five
36-
country packages, ~210s of it) and Cloud Run hard-caps total startup-probe time at 240s —
37-
the default TCP probe already sits at that cap, and **no probe configuration can extend
38-
it**. Two mitigations are therefore built in:
38+
The app import is slow (measured container boot p50 171.5s / p95 215.2s over 7 days,
39+
~161–168s on cpu-boosted revisions; dominated by `policyengine_api/country.py`
40+
instantiating five country packages — see
41+
[`history/pr4-stage2-runtime-timing.md`](history/pr4-stage2-runtime-timing.md)) and
42+
Cloud Run hard-caps total startup-probe time at 240s — the default TCP probe already
43+
sits at that cap, and **no probe configuration can extend it**. Two mitigations are
44+
therefore built in:
3945

4046
- `gcp/cloud_run/start.sh` runs **gunicorn with `uvicorn.workers.UvicornWorker` and no
4147
`--preload`**: the gunicorn master binds the listen socket in milliseconds (satisfying
4248
the startup probe immediately), and the import happens in the worker after fork.
4349
`--timeout 0` is required — a worker mid-import does not heartbeat, and gunicorn's
4450
default 30s watchdog would kill it. `--keep-alive 5` and `--forwarded-allow-ips '*'`
45-
preserve the previous uvicorn keep-alive and proxy-header behavior.
46-
`--max-requests 500` (+jitter 100) recycles workers to bound slow memory growth under
47-
sustained load; a recycling worker re-imports for ~3 minutes, halving instance
48-
capacity while it does.
49-
- Candidate deploys set `--cpu-boost` to shorten the import.
51+
preserve the previous uvicorn keep-alive and proxy-header behavior. Worker recycling
52+
(`--max-requests`) is deliberately NOT enabled: with 2 workers and a multi-minute
53+
re-import that runs without cpu-boost (and CPU-throttled at idle), recycling risks
54+
correlated zero-ready-worker windows; it needs its own qualification before adoption.
55+
- Candidate deploys set `--cpu-boost` to shorten the import (instance startup only).
5056

5157
## Runtime shape and scaling (from the Stage 2 qualification)
5258

@@ -74,6 +80,17 @@ Values measured and justified in
7480
--min-instances 1
7581
```
7682

83+
**When:** once, immediately after the Stage 3 PR merges — before evaluating the
84+
Stage 3 exit gates (the idle-readiness gate cannot pass without it) and before any
85+
public traffic. **Verify** it took, and re-verify during ramp incident response:
86+
87+
```bash
88+
gcloud run services describe policyengine-api \
89+
--project policyengine-api --region us-central1 --format yaml | grep -i minscale
90+
# expect a service-level minScale annotation of 1; a minScale under
91+
# spec.template (revision-level) would be the per-tag cost bomb — remove it.
92+
```
93+
7794
Rationale: the user-facing scale-from-zero wake was measured at 282.8s — 17s under
7895
the 300s request timeout — so a cold start must never sit on a public request path.
7996

docs/migration/history/pr4-stage2-runtime-timing.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,14 @@ memory-safe.
120120
values untenable).
121121
2. Service-level `min-instances 1` (manual, runbook) + unit invariant keeping
122122
revision-level at 0.
123-
3. Consider gunicorn `--max-requests` + jitter (worker recycling) to cap the
124-
slow memory growth behind the 69% knife-edge.
123+
3. Worker recycling (gunicorn `--max-requests`) remains a candidate mitigation
124+
for the 69% memory knife-edge, but review analysis showed it must be
125+
qualified on its own before adoption: the counter advances on cheap
126+
requests too (at peak, a 500-request budget recycles faster than the
127+
~170s+ re-import), small jitter cannot decorrelate two workers (the
128+
survivor absorbs all traffic and burns its headroom during the peer's
129+
re-import), and a mid-life re-import runs without cpu-boost, CPU-throttled
130+
at idle. Requires a recycling-enabled soak and sizing against the real
131+
request mix (likely O(5,000+) with jitter ~50%).
125132
4. Re-baseline after metadata slimming lands; it dominates warm-parity and
126133
transfer costs.

scripts/measure_cloud_run_runtime.py

Lines changed: 28 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import asyncio
1818
import copy
1919
import json
20+
from collections import Counter
2021
import random
2122
import sys
2223
import time
@@ -40,8 +41,6 @@
4041
def _percentile(values: list[float], percentile: float) -> float | None:
4142
if not values:
4243
return None
43-
if len(values) == 1:
44-
return round(values[0], 2)
4544
index = round((len(values) - 1) * percentile)
4645
return round(sorted(values)[index], 2)
4746

@@ -50,19 +49,6 @@ def _utc_now_iso() -> str:
5049
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
5150

5251

53-
def _parse_cache_bust(raw: str) -> float:
54-
if raw == "always":
55-
return 1.0
56-
if raw == "never":
57-
return 0.0
58-
share = float(raw)
59-
if not 0.0 <= share <= 1.0:
60-
raise argparse.ArgumentTypeError(
61-
"--cache-bust must be always, never, or 0.0-1.0"
62-
)
63-
return share
64-
65-
6652
def _classify(status_code: int) -> str:
6753
if status_code == 429:
6854
return OUTCOME_HTTP_429
@@ -99,7 +85,7 @@ async def _client_loop(
9985
client: httpx.AsyncClient,
10086
args: argparse.Namespace,
10187
base_payload: dict,
102-
cache_bust_share: float,
88+
cache_bust: bool,
10389
warmup_until: float,
10490
deadline: float,
10591
records: list[dict],
@@ -111,7 +97,7 @@ async def _client_loop(
11197
is_calculate = rng.random() < args.calculate_share
11298
if is_calculate:
11399
payload = copy.deepcopy(base_payload)
114-
if rng.random() < cache_bust_share:
100+
if cache_bust:
115101
payload["_loadtest"] = f"{args.cell_label}-{client_index}-{seq}"
116102
method, path = "POST", CALCULATE_PATH
117103
timeout = args.timeout_calculate
@@ -120,44 +106,32 @@ async def _client_loop(
120106
method, path = "GET", METADATA_PATH
121107
timeout = args.timeout_metadata
122108

123-
record = {
124-
"cell": args.cell_label,
125-
"client": client_index,
126-
"seq": seq,
127-
"endpoint": "calculate" if is_calculate else "metadata",
128-
"wall_start": time.time(),
129-
"warmup": time.monotonic() < warmup_until,
130-
}
109+
warmup = time.monotonic() < warmup_until
131110
started = time.perf_counter()
111+
status = 0
132112
try:
133113
response = await client.request(method, path, json=payload, timeout=timeout)
134-
record["latency_ms"] = round((time.perf_counter() - started) * 1000, 2)
135-
record["status"] = response.status_code
136-
record["bytes"] = len(response.content)
137-
record["outcome"] = _classify(response.status_code)
114+
status = response.status_code
115+
outcome = _classify(status)
138116
except httpx.TimeoutException:
139-
record["latency_ms"] = round((time.perf_counter() - started) * 1000, 2)
140-
record["status"] = 0
141-
record["bytes"] = 0
142-
record["outcome"] = OUTCOME_TIMEOUT
143-
except httpx.HTTPError as error:
144-
record["latency_ms"] = round((time.perf_counter() - started) * 1000, 2)
145-
record["status"] = 0
146-
record["bytes"] = 0
147-
record["outcome"] = OUTCOME_CONNECT_ERROR
148-
record["error"] = f"{type(error).__name__}: {error}"
149-
records.append(record)
117+
outcome = OUTCOME_TIMEOUT
118+
except httpx.HTTPError:
119+
outcome = OUTCOME_CONNECT_ERROR
120+
records.append(
121+
{
122+
"endpoint": "calculate" if is_calculate else "metadata",
123+
"warmup": warmup,
124+
"latency_ms": round((time.perf_counter() - started) * 1000, 2),
125+
"status": status,
126+
"outcome": outcome,
127+
}
128+
)
150129

151130

152131
def _summarize_endpoint(records: list[dict], duration_seconds: float) -> dict:
153132
latencies = [r["latency_ms"] for r in records if r["outcome"] == OUTCOME_OK]
154-
outcome_counts: dict[str, int] = {}
155-
status_counts: dict[str, int] = {}
156-
for record in records:
157-
outcome_counts[record["outcome"]] = outcome_counts.get(record["outcome"], 0) + 1
158-
status_counts[str(record["status"])] = (
159-
status_counts.get(str(record["status"]), 0) + 1
160-
)
133+
outcome_counts = Counter(r["outcome"] for r in records)
134+
status_counts = Counter(str(r["status"]) for r in records)
161135
return {
162136
"request_count": len(records),
163137
"achieved_rps": round(len(records) / duration_seconds, 3),
@@ -166,15 +140,15 @@ def _summarize_endpoint(records: list[dict], duration_seconds: float) -> dict:
166140
"p95_latency_ms": _percentile(latencies, 0.95),
167141
"p99_latency_ms": _percentile(latencies, 0.99),
168142
"max_latency_ms": max(latencies) if latencies else None,
169-
"outcome_counts": outcome_counts,
170-
"status_counts": status_counts,
143+
"outcome_counts": dict(outcome_counts),
144+
"status_counts": dict(status_counts),
171145
}
172146

173147

174148
async def _run(args: argparse.Namespace) -> dict:
175149
base_payload = json.loads(Path(args.calculate_payload).read_text())
176150
base_payload["household"]["axes"][0][0]["count"] = args.axes_count
177-
cache_bust_share = _parse_cache_bust(args.cache_bust)
151+
cache_bust = args.cache_bust == "always"
178152

179153
limits = httpx.Limits(max_connections=args.clients + 2)
180154
async with httpx.AsyncClient(
@@ -200,7 +174,7 @@ async def _run(args: argparse.Namespace) -> dict:
200174
client=client,
201175
args=args,
202176
base_payload=base_payload,
203-
cache_bust_share=cache_bust_share,
177+
cache_bust=cache_bust,
204178
warmup_until=warmup_until,
205179
deadline=deadline,
206180
records=records,
@@ -248,8 +222,10 @@ def main() -> int:
248222
parser.add_argument("--axes-count", type=int, default=101)
249223
parser.add_argument(
250224
"--cache-bust",
225+
choices=("always", "never"),
251226
default="always",
252-
help="always | never | fraction 0.0-1.0 of calculate requests busted",
227+
help="always = every calculate does real engine work (capacity cells); "
228+
"never = identical payloads measure the Redis cache path",
253229
)
254230
parser.add_argument("--seed", type=int, default=42)
255231
parser.add_argument(
@@ -264,7 +240,6 @@ def main() -> int:
264240
help="seconds to wait for /readiness-check (cold imports take ~3 min)",
265241
)
266242
args = parser.parse_args()
267-
_parse_cache_bust(args.cache_bust)
268243

269244
summary = asyncio.run(_run(args))
270245
json.dump(summary, sys.stdout, indent=2)

tests/unit/test_cloud_run_deploy_scripts.py

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -369,16 +369,11 @@ def test_deploy_cloud_run_candidate_pins_runtime_shape():
369369
)
370370

371371
assert result.returncode == 0, result.stderr
372-
# Stage 2 qualification values, pinned explicitly on every deploy so a
373-
# polluted service template can never leak into a candidate (gcloud
374-
# inherits unspecified fields; `--concurrency default` is the platform
375-
# default of 640, not the historical 80).
376-
assert "--concurrency 4" in result.stdout
377-
assert "WEB_CONCURRENCY=2" in result.stdout
378-
# Warm capacity is a service-level setting made outside CI: revision-level
379-
# min-instances above zero would keep a warm 16Gi instance per
380-
# accumulated revision tag.
381-
assert "--min-instances 0" in result.stdout
372+
# Stage 2-qualified values, pinned on every deploy — rationale in
373+
# docs/migration/cloud-run-operations.md ("Runtime shape and scaling").
374+
assert "--concurrency 4 " in result.stdout
375+
assert "WEB_CONCURRENCY=2 " in result.stdout
376+
assert "--min-instances 0 " in result.stdout
382377

383378

384379
def test_push_workflow_pins_cloud_run_scaling_per_job():

0 commit comments

Comments
 (0)