Skip to content

fix(relay): land the cell backend drain and request logging inside the same-cap wave - #21860

Merged
Jinwoo-H merged 2 commits into
mainfrom
relay-same-cap-targets-cell-backend
Sep 21, 2026
Merged

Jinwoo-H merged 2 commits into
mainfrom
relay-same-cap-targets-cell-backend

Conversation

@Jinwoo-H

@Jinwoo-H Jinwoo-H commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

ELI5

Two settings on Relay's load balancers were written down months ago but never switched on, because the only button that would have pushed them would have restarted every Relay server in the fleet at once. This PR moves both into the existing one-server-at-a-time rollout, so they go out with the routine update, first on the single test server.

The settings: (1) when a server is taken out of rotation, the load balancer waits 1 minute instead of 5 before cutting leftover connections; (2) the load balancer writes one log line per connection when it closes, so we can join "which server did we hand this user to" with "what actually happened to that connection".

What Changed

Before. Two things were declared in cloud/infra/terraform/relay-gce-cells.tf for google_compute_backend_service.relay_gce_cell[<cell>] and neither had ever reached a live US cell:

Neither could be applied. Every production apply since that commit is a per-cell targeted plan naming only the instance template and the MIG, so the backend service has never been in a plan. And a root plan cannot be used: targeting the backend services alone still drags in every cell's template, which standing image drift turns into a fleet-wide roll. Measured on the production root at main 76d8760:

terraform plan -target='google_compute_backend_service.relay_gce_cell["production-gce-c1"]' \
  -var-file=environments/production.tfvars
  → Plan: 29 to add, 30 to change, 29 to destroy

All 29 templates "must be replaced" (metadata_startup_script forces replacement), all 29 MIGs updated. The lead saw the same shape earlier tonight and did not apply.

Why an indexed target still pulls in everything. Terraform applies -target to the plan graph before dynamic expansion. The index filters the targeted resource's own instances, but the nodes it pulls in as dependencies are whole resources: backend["c1"] → the entire google_compute_instance_group_manager.relay_gce_cell node → the entire google_compute_instance_template.relay_gce_cell node, all 29 instances of each. It is a dependency-expansion effect, not a whole-map reference in the config — the backend's only per-cell reference is google_compute_instance_group_manager.relay_gce_cell[each.key].instance_group, and there is no depends_on on the backend, the URL map, or the health check pointing at every cell.

That also explains why the same-cap job is unaffected: it already names the template and the MIG with explicit indices, so those two nodes are in the target set with an instance filter, and the backend's dependency pull-in cannot widen them.

After. Both settings land one cell at a time, inside the wave that already rolls that cell, starting with the canary. No extra dispatch, no extra restart.

Mechanism.

  1. .github/workflows/cloud-deploy-relay-production-same-cap-job.yml — both terraform plan invocations (the rollback-resume convergence check and the apply) now pass "-target=google_compute_backend_service.relay_gce_cell[\"${TARGET_CELL_ID}\"]" alongside the template and MIG, the same per-cell triple .github/workflows/cloud-deploy-relay-asia-topology.yml builds. The apply consumes the saved plan file, so no apply re-lists targets. The -var-file mask the job builds (relay-same-cap.tfvars.json — the committed relay_gce_cells map with only the target cell's image overridden) is untouched: the backend reads local.relay_gce_topology and var.relay_gce_cell_log_sample_rate, not that map, so the added target does not widen the template mask.

  2. cloud/dev/scripts/validate-relay-capacity-plan.mjs — the reviewed plan validator now accepts, on top of everything it accepted before, at most one extra resource: an in-place update of this cell's backend service whose changed paths are a non-empty subset of {connection_draining_timeout_sec, log_config.0}, with connection_draining_timeout_sec === RELAY_CELL_CONNECTION_DRAIN_SECONDS and log_config exactly one block with enable === true and sample_rate === RELAY_CELL_LOG_SAMPLE_RATE. Both constants are imported from validate-relay-asia-topology-plan.mjs; neither number is restated. Each path is optional because a cell that already has one plans no change for it — the Asia cells' backends were created by the Asia topology workflow and already carry log_config.

    Everything else still fails closed: a third changed path, a different sample rate, enable: false, a drain value that is not the constant, a backend for another cell id, a replacement instead of an update, a backend change in any mode other than same-cap-cell, and any unexpected unknown path other than provider-computed fingerprint/generated_id.

    RELAY_CELL_LOG_SAMPLE_RATE is new. It is not a topology local but the declared default of var.relay_gce_cell_log_sample_rate; the existing constants census test was extended to equate it with variables.tf and to assert that neither production.tfvars nor staging.tfvars overrides it, so the constant cannot silently stop describing what a cell gets.

  3. The accepted paths are reported as backendUpdate: [...] and are deliberately not counted in changes. Two callers read changes and would misread an inflated count: the apply step's stranded branch (ROLLBACK_STAGE = stranded && .changes == 0 → explicit rolling-action replace), and the resume step's drift branch (.changes == 2, the reviewed template-and-MIG pair). A backend attribute replaces no instance, so it must not suppress the stranded roll. Resume additionally accepts .changes == 0 and ((.backendUpdate // []) | length) > 0: a cell whose template and MIG have converged but whose backend has not. That is safe precisely because neither backend attribute restarts anything, which is the property the resume step protects.

Why

The per-cell same-cap job is the only place in the repo that plans and applies Relay cell infrastructure under a per-cell target set and a reviewed plan validator. Alternatives considered:

  • Root apply targeting the backend services. Rejected — that is the 29-replacement plan measured above.
  • Repin the 29 cell images in tfvars first, then root-apply. Larger and riskier, and the drift returns the next time a cell rolls. It postpones the fleet-roll hazard rather than removing it.
  • A separate one-off workflow for the backends. A second apply path and a second validator to keep in sync, against "reuse before reimplementing". The existing job already holds the rollout lock, the canary gate, the monitor evidence, and the health gate.
  • Counting the backend in changes. Silently breaks the stranded branch and the resume branch, both of which are about instance restarts.
  • Accepting the drain only (what the first commit on this branch did). Proven wrong against a real plan; see Testing.

Linked Issue

N/A — follow-up to #21848 (merged as 4f839cc), which introduced the drain value this makes applicable.

Visual Proof

N/A — CI workflow and plan-validator change, no user-visible UI.

The behavioural proof is on the canary, from the job's own step outputs: apply-completed-at → old-VM power-off, about 5m55s under the 300 s drain. Two caveats for whoever reads that number:

  • Whether the new 60 s takes effect on the same wave that applies it or only the next depends on the ordering Terraform picks between the MIG update and the backend update inside one apply. If the MIG starts recreating before the backend attribute is written, that cell drains on the old 300 s and the gain appears on the following cell.
  • So the batch measures the interval too, not only the canary. A canary still at ~5m55s with the second cell at ~1m55s is the expected ordering, not a failed change.

LB request logging can be confirmed independently after the canary: resource.type="http_load_balancer" entries for orca-cloud-relay-gce-<cell>, one per WebSocket, written at close.

Testing

  • I manually tested these changes locally
  • Automated tests added/updated

Proof against the real production plan (read-only; plan only, never applied, every .tfplan deleted). Reproduced the job's masked per-cell plan on the production root at main 76d8760, building relay-same-cap.tfvars.json exactly as the job does, with the three targets:

--- resource list (production-gce-c7) ---
google_compute_backend_service.relay_gce_cell["production-gce-c7"]      update
google_compute_instance_group_manager.relay_gce_cell["production-gce-c7"] update
google_compute_instance_template.relay_gce_cell["production-gce-c7"]    create+delete
Plan: 1 to add, 2 to change, 1 to destroy.

Exactly {this cell's template, this cell's MIG, this cell's backend} and nothing else. Same result for production-gce-c1. The backend's changed paths on that real plan are ["connection_draining_timeout_sec", "log_config.0"] (before.log_config: [], after.log_config: [{enable: true, sample_rate: 1}], drain 300 → 60), and after_unknown is {}.

This validator on that plan:

verdict: {"mode":"same-cap-cell","changes":2,
          "backendUpdate":["connection_draining_timeout_sec","log_config.0"]}

Control — the drain-only validator from this branch's first commit, on the same plan:

Error: google_compute_backend_service.relay_gce_cell["production-gce-c7"] changes outside the reviewed capacity fields

That is the mid-wave stranding this second commit fixes.

Unit tests. cloud/dev/scripts/validate-relay-capacity-plan.test.mjs, test a same-cap roll may carry only this cell backend drain and request logging, fixtures for: both attributes alongside the template/MIG pair; drain alone; log_config alone; neither (unchanged {changes: 2}); a drain-and-logging-only plan on a converged cell ({changes: 0, backendUpdate: [...]}); a third changed attribute (fails); a different sample_rate (fails); enable: false (fails); a drain value that is not the constant (fails); a backend for another cell id (fails); a backend replacement rather than an update (fails); and the same plan under --mode cell (still fails).

cloud/dev/scripts/relay-same-cap-script-census.test.mjs gained a contract test pinning the job's target list: every terraform plan the job runs targets exactly the template, the MIG, and the backend, each scoped to ${TARGET_CELL_ID}, and nothing else. Control-run against the pre-change workflow it fails with the backend entry missing, so it is not vacuous.

cloud/dev/scripts/validate-relay-asia-topology-plan.test.mjs extends the existing tf-vs-JS constants census to the new sample-rate constant. cloud/dev/scripts/relay-regional-rehome-workflow.test.mjs pins the resume step's jq filter verbatim and was updated.

Commands (macOS):

node --test dev/scripts/validate-relay-capacity-plan.test.mjs
  → tests 9, pass 9, fail 0

# cloud "pretest" list (18 files)
  → tests 157, pass 157, fail 0

# cloud "test" dev-scripts list (53 files)
  → tests 628, pass 627, fail 1

The one failure is dev/scripts/relay-load-control-peer.test.mjs: Cannot find module '@orca-cloud/relay-contract' — a pre-existing local install gap (cloud/node_modules is absent in this worktree), unrelated to these files; CI installs the workspace.

actionlint .github/workflows/cloud-deploy-relay-production-same-cap-job.yml

Byte-identical to the same command on origin/main's copy: one pre-existing finding, label "blacksmith-2vcpu-ubuntu-2204" is unknown [runner-label]. cloud/dev/scripts/*.mjs is outside the oxlint target set.

Platforms: a GitHub Actions workflow (ubuntu runner) and Node scripts; no platform-dependent code. SSH/remote, mobile, and renderer untouched.

AI Disclosure

Review

Two things worth pushing on:

  1. The step is still named "Apply only the selected same-cap template and MIG" and now also applies two backend attributes. Left as-is because three test files anchor on that exact string; the added in-step comment and the census target-list test carry the meaning. Say the word and I will rename it and update the anchors.
  2. The unknown-path allow-list for the backend update is fingerprint and generated_id. The real plan's after_unknown was {}, so neither was needed; they are defensive and gated on actually being marked unknown. If some future plan marks a different computed attribute unknown, the wave fails closed and the list needs one entry.

Both settings are reviewed per cell before anything is applied, so a wave that meets something unexpected stops before the apply rather than shipping it.

Agent skill upstream boundary

  • Not applicable.

Notes

No security surface change: the validator becomes more permissive for two attributes on one resource, under one mode, at exact values, and stays fail-closed everywhere else. Enabling LB request logging writes one Cloud Logging entry per WebSocket at close (sample_rate = 1) for the cells the wave reaches; it records client IP, status, and latency, which is what makes assign-to-connection joins exact, and it is the configuration already reviewed and committed. No wire, mobile, or backwards-compatibility impact.

Checklist

  • This PR is small and focused
  • I explained what changed and why
  • N/A with reason for visuals
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered (N/A)
  • Relevant tests, a real production plan, and actionlint run locally; CI covers the rest

The 60 s connection drain timeout merged in #21848 has no safe apply path.
A root plan scoped to the backend services alone still pulls every
`google_compute_instance_template.relay_gce_cell` in as a dependency, and
standing image drift turns all 29 into replacements, so applying it would roll
the fleet at once.

Add `google_compute_backend_service.relay_gce_cell["${TARGET_CELL_ID}"]` to
both plan invocations in the per-cell same-cap job, next to the template and
MIG it already targets, and teach the reviewed plan validator to allow exactly
one extra change: an in-place update of that one cell's backend whose only
changed attribute is `connection_draining_timeout_sec`, landing on the
constant `validate-relay-asia-topology-plan.mjs` exports. Any other attribute,
any other resource, or a backend for another cell still fails the validator.

The accepted update is reported as `connectionDrainUpdate` and kept out of
`changes`, so the apply step's stranded branch and the resume step's drift
branch keep reading the template-and-MIG count they were written against; the
resume branch additionally accepts a plan whose only pending change is that
drain update, which restarts nothing.

Claude-Session: relay-same-cap-targets-cell-backend
@pullfrog

pullfrog Bot commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

This run was cancelled 🛑

The workflow was cancelled before completion. Please check the link below for details.

Pullfrog  | View workflow run | via Pullfrog | 𝕏

@coderabbitai

coderabbitai Bot commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: 490e1853-4ef6-4e6c-9928-21aaf164b220

📥 Commits

Reviewing files that changed from the base of the PR and between e0b717d and ef197e6.

📒 Files selected for processing (5)
  • .github/workflows/cloud-deploy-relay-production-same-cap-job.yml
  • cloud/dev/scripts/relay-regional-rehome-workflow.test.mjs
  • cloud/dev/scripts/relay-same-cap-script-census.test.mjs
  • cloud/dev/scripts/validate-relay-capacity-plan.mjs
  • cloud/dev/scripts/validate-relay-capacity-plan.test.mjs

Included review availability: Your plan provides up to 10 included reviews per hour; 8 remain after this review.


📝 Walkthrough

Walkthrough

The change adds validation for a reviewed connection-draining timeout update on the selected same-cap cell backend service. The validator reports this update separately from template and MIG changes. Production plans now target the backend service in resume and apply paths. Workflow and script tests cover valid drain updates, invalid backend changes, drain-only results, and exact target scoping.

Priority: ➖ Normal

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 4 functions across 4 files. (1 skipped: 1… Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly identifies the main change: adding cell backend draining and request logging to the Relay same-cap rollout.
Description check ✅ Passed The description is detailed and covers the required sections, including the change, rationale, testing, visual proof, review notes, and checklist. It references follow-up issue #21848, explains why vi…
Full details: Docstring Coverage

Explanation

Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 4 functions across 4 files. (1 skipped: 1 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…ogging

A read-only production plan for production-gce-c7 showed the live US cell
backends carry no `log_config` at all, while relay-gce-cells.tf has declared
`log_config { enable = true, sample_rate = var.relay_gce_cell_log_sample_rate }`
on every cell backend since the Terraform root landed in 3eec77c (#18413).
Nothing has applied it because every production apply since is a per-cell
targeted plan that names only the template and the MIG.

So the real canary plan's backend moves two paths, not one:
`["connection_draining_timeout_sec", "log_config.0"]`. The drain-only validator
rejected exactly that plan, which would have stranded the cell mid-wave after
the drain had already started.

Accept both, each optional, for this cell's backend only: the drain landing on
RELAY_CELL_CONNECTION_DRAIN_SECONDS, and a log_config of exactly one block with
`enable = true` and `sample_rate` equal to RELAY_CELL_LOG_SAMPLE_RATE, the
declared default of a variable no environment file overrides. Any third path,
a different sample rate, disabled logging, another cell, or a replacement still
fails. The accepted paths are reported as `backendUpdate`, which the resume
branch now reads instead of the drain-only flag.

Verified against the real production plan: the masked, three-target plan for
production-gce-c7 contains exactly that cell's template, MIG, and backend
service and nothing else, and this validator returns
`{"changes":2,"backendUpdate":["connection_draining_timeout_sec","log_config.0"]}`.

Claude-Session: relay-same-cap-targets-cell-backend
@Jinwoo-H Jinwoo-H changed the title chore(relay): target each cell's backend service from the same-cap job fix(relay): land the cell backend drain and request logging inside the same-cap wave Sep 21, 2026
@Jinwoo-H
Jinwoo-H merged commit 2524737 into main Sep 21, 2026
25 checks passed
@Jinwoo-H
Jinwoo-H deleted the relay-same-cap-targets-cell-backend branch September 21, 2026 00:36
Jinwoo-H added a commit that referenced this pull request Sep 21, 2026
…d update on resume (#21865)

* fix(relay): bound the same-cap shadow gate and apply a resumed backend update

Two findings both adversarial reviews of tonight's merged set agree on.

The report-only shadow health gate (#21849) had `continue-on-error: true` but
no step timeout. That bounds the step's contribution to the job outcome, not
its clock. Its reads are serialised, and a failure that answers nothing slowly
— an expired credential, a project-wide Logging 429 storm — makes every read
cost its full 3 x 60 s retry budget, so the cost scales with the roll window:
roughly 8S + 2 reads for S ten-minute sub-windows. A 40-minute window is about
34 reads, or 108 minutes, against the job's `timeout-minutes: 75`. A cancelled
job cannot be absorbed by continue-on-error, fires the failure-gated cleanup
isolation on an already-restored cell, and stops the strict next-cell chain.

Give the step `timeout-minutes: 5` and the artifact upload `timeout-minutes: 2`.
A timed-out step is a failed step, which continue-on-error covers, so the job
stays green. Inside the script, stop reading after an overall four-minute
deadline and report the remaining checks unverified, so the normal outcome is a
written verdict rather than a killed process; the step timeout is then only for
a hung process. The census test pins both timeouts and that the deadline leaves
the step time to write its verdict.

The resume branch (#21860) accepted `changes == 0` with a non-empty
`backendUpdate` as complete and applied nothing, so a resumed cell silently
kept the 300-second drain and no request logging behind a green resume. That
shape means the template and MIG are converged and only this cell's reviewed
backend update is left, so apply the saved resume plan — the validator has
already bounded it to this cell's backend and neither attribute restarts an
instance — then continue as converged. Template-and-MIG drift still applies
nothing, which is what a resume means, and a stranded cell's explicit MIG
replace is unchanged.

Claude-Session: relay-same-cap-gate-timeout-and-resume

* fix(relay): raise the shadow gate bounds clear of a healthy gate's read time

A healthy gate is already minutes of serial reads on the 2-vcpu runner, so a
four-minute deadline would report unverified tails on ordinary days and stop
the shadow roll measuring the comparison it exists for. Raise both together:
the step to eight minutes and the script's own deadline to seven, keeping the
census pin that the deadline leaves the step room to write its verdict. The
job budget is unaffected: a ~14-minute cell plus eight is well inside 75.

Claude-Session: relay-same-cap-gate-timeout-and-resume
Jinwoo-H added a commit that referenced this pull request Sep 22, 2026
…pacity role cannot update it (#22140)

The capacity role the same-cap wave authenticates as, orcaRelayProductionCapacity,
has no compute.backendServices.update. Since #21860 added
`google_compute_backend_service.relay_gce_cell["${TARGET_CELL_ID}"]` to both of the
job's plan invocations, every wave has therefore created the new instance template,
modified the MIG, and then failed 403 on the backend, leaving the cell isolated with
its trust probe, admission restore, and shadow gate all skipped. Run 35684694704 on
production-gce-c7 is the first one that hit it in production.

Drop the backend target from both plans and restore the resume gate to exactly
`.changes == 2` (the template-and-MIG rollback-image drift) or a converged plan,
removing the backend-only resume apply #21865 added on top. A resume applies nothing
again, which is what a resume means.

The validator keeps its bound on a cell backend update, so it still reports one and
refuses anything wider, but a wave plan can no longer contain one. The drain timeout
from #21848 and the log_config from #21860 need a root apply by a principal that holds
the permission; granting the capacity role that permission is itself a root apply, so
it can follow as its own change rather than blocking every wave in the meantime.

Claude-Session: ced32ebb-7155-4413-adad-1eccd14c2010
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant