Files
orca/cloud/dev/scripts/relay-same-cap-shadow-gate.mjs
T
Jinwoo Hong 68b186e2aa fix(relay): end the restart-safe quiet at the drain's pace window, not a window after it (#28677)
* fix(relay): end the restart-safe quiet at the drain's pace window, not a window after it

A paced drain tells its last host when the window ends, so the restart-safe
wait has to see an empty runtime reaching past that point. It counted a full
window of quiet samples from the first empty one instead, and the runtime only
empties as the window ends, so every same-cap cell waited the window twice:
about 25 minutes per Asia cell at 1200 s on 2026-10-13, and 5 minutes per
300 s cell.

The window is now measured from the verifier's own start, which runs after the
cell accepted the drain and so ends no earlier than the cell's own window. A
pass still needs two consecutive restart-safe samples, the last taken at or
after that end, and any non-zero runtime counter or open migration still
restarts the count. Progress lines report paceWindowRemainingMs in place of the
old sample target.

Claude-Session: f2fe8cb5-4467-4a25-8aab-98598b70791f

* fix(relay): drop the same-cap drain timeout's double-wait allowance and stale quiet wording

The drain step's timeout added the window's excess over 300000 only because
restart-safe used to wait a second window after the cell emptied, so it is
now the 15-minute migration lease plus the window for every pace (35 minutes
at 1200000, unchanged at 300000 and below). Docs, workflow comments and the
shadow gate's settledAfterSeconds note now describe the window-end rule. The
always-quiet test starts its clock late, so a window measured from zero
fails it, and the overlapping late-control test is folded away.

Claude-Session: f2fe8cb5-4467-4a25-8aab-98598b70791f

* docs(relay): state the restart-safe window-end rule exactly

Claude-Session: f2fe8cb5-4467-4a25-8aab-98598b70791f

* docs(relay): the slowest restart-safe wait is now 35 minutes

Claude-Session: f2fe8cb5-4467-4a25-8aab-98598b70791f
2026-10-14 15:35:31 -04:00

461 lines
18 KiB
JavaScript

#!/usr/bin/env node
// Post-wave shadow health gate for a same-cap cell roll. Reads exactly the oracles an operator
// reads by hand today, writes a PASS / WARN / WOULD_BLOCK verdict with its numbers to a JSON
// artifact and the step summary, and always exits 0 on a verdict: this runs in report-only mode so
// its calls can be compared with the operator's over a full roll before it is allowed to block.
//
// Every filter is built from validated, pattern-pinned inputs and handed to gcloud as argv, never
// through a shell.
import { execFile } from 'node:child_process'
import { appendFile, writeFile } from 'node:fs/promises'
import { pathToFileURL } from 'node:url'
import { promisify } from 'node:util'
import {
BACKGROUND_MINUTES,
DIRECTOR_METRICS_INTERVAL_MS,
ENTRY_LIMIT,
PACE_CHECKS,
FLEET_POOL_CELL_IDS,
SHADOW_GATE_THRESHOLDS,
combineVerdict,
countByMinute,
drainReturnByMinute,
formatTimestamp,
backgroundOf,
judgeCellServing,
judgeCloudSqlFatal,
judgeDrainDeferrals,
judgeNonDrain503Budget,
judgePool,
minuteKey,
minutesOf,
parseTimestamp,
renderStepSummary,
resolveWindow,
splitWindow,
withoutDrainDeferrals
} from './relay-same-cap-shadow-gate-verdict.mjs'
import { SAME_CAP_DRAIN_PACE_WINDOWS_MS } from './relay-production-same-cap-wave.mjs'
const execFileAsync = promisify(execFile)
const CELL_ID = /^production-gce-c[1-9][0-9]*$/
const CELL_HOST = /^c[1-9][0-9]*\.relay\.onorca\.dev$/
const PROJECT_ID = /^[a-z][a-z0-9-]{4,28}[a-z0-9]$/
const SERVICE_NAME = /^[a-z][a-z0-9-]{0,62}$/
const COUNT = /^(0|[1-9][0-9]*)$/
export const READ_ATTEMPTS = 3
const READ_RETRY_DELAY_MS = 5000
const READ_TIMEOUT_MS = SHADOW_GATE_THRESHOLDS.readTimeoutMs
const OVERALL_DEADLINE_MS = SHADOW_GATE_THRESHOLDS.overallDeadlineMs
// json(timestamp) over a busy minute is a few hundred KB; leave room for the widest sub-window.
const READ_MAX_BUFFER_BYTES = 256 * 1024 * 1024
export function parseShadowGateArguments(argv) {
const values = new Map()
for (let index = 0; index < argv.length; index += 2) {
if (!argv[index].startsWith('--')) throw new Error(`expected a flag, got ${argv[index]}`)
values.set(argv[index].slice(2), argv[index + 1])
}
const required = (name, pattern) => {
const value = values.get(name) ?? ''
if (!pattern.test(value)) throw new Error(`--${name} is not acceptable: ${value}`)
return value
}
const optionalCount = (name) => (values.get(name) ? Number(required(name, COUNT)) : null)
const config = {
cellId: required('cell-id', CELL_ID),
cellHost: required('cell-host', CELL_HOST),
projectId: required('project-id', PROJECT_ID),
directorService: required('director-service', SERVICE_NAME),
drainStartedAt: values.get('drain-started-at') || '',
// The listener lands while the MIG is still converging, so the boot search has to open at the
// apply's start; a bound taken at its completion is already past the announcement it looks for.
applyStartedAt: values.get('apply-started-at') || '',
applyCompletedAt: values.get('apply-completed-at') || '',
verifyEndedAt: values.get('verify-ended-at') || '',
outputFile: values.get('output-file') || '',
summaryFile: values.get('summary-file') || '',
drainPaceWindowMs: Number(required('drain-pace-window-ms', COUNT)),
// Empty on a resumed rollback, which never drains.
drainAppliedPaceWindowMs: optionalCount('drain-applied-pace-window-ms'),
drainSettledAt: values.get('drain-settled-at') || '',
targetHosts: optionalCount('target-hosts')
}
if (!SAME_CAP_DRAIN_PACE_WINDOWS_MS.includes(config.drainPaceWindowMs)) {
throw new Error(`--drain-pace-window-ms is not acceptable: ${config.drainPaceWindowMs}`)
}
if (config.drainSettledAt) parseTimestamp(config.drainSettledAt, '--drain-settled-at')
if (!config.cellHost.startsWith(`${config.cellId.replace('production-gce-', '')}.`)) {
throw new Error(`--cell-host ${config.cellHost} is not the host of ${config.cellId}`)
}
if (!config.outputFile) throw new Error('--output-file is required')
return config
}
function timestampBounds({ startedAt, endedAt }) {
return `timestamp>="${formatTimestamp(startedAt)}" AND timestamp<"${formatTimestamp(endedAt)}"`
}
/**
* Runs one bounded read with retries. A read that cannot complete is reported as failed rather than
* thrown: a missing oracle must surface as an unverified check, not as a crashed gate.
*/
async function retryingRead(reader, read) {
let lastError
for (let attempt = 1; attempt <= READ_ATTEMPTS; attempt += 1) {
// Every remaining read short-circuits once the budget is gone, so the gate always reaches a
// verdict instead of being killed part-way through with nothing written.
const remainingMs = reader.deadlineAt - reader.now()
if (remainingMs <= 0) return { failed: true, error: 'shadow gate read deadline exceeded' }
try {
return { ...(await read(Math.min(reader.readTimeoutMs, remainingMs))), failed: false }
} catch (error) {
lastError = error
if (attempt < READ_ATTEMPTS) {
await new Promise((resolve) => setTimeout(resolve, reader.retryDelayMs))
}
}
}
return { failed: true, error: String(lastError?.message ?? lastError) }
}
async function readLogEntries(reader, { filter, projection, limit = ENTRY_LIMIT }) {
const args = [
'logging', 'read', filter,
'--project', reader.projectId,
'--format', projection,
'--limit', String(limit),
'--order', 'desc'
]
const read = await retryingRead(reader, async (timeoutMs) => {
const { stdout } = await reader.runGcloud(args, { timeoutMs })
return { entries: JSON.parse(stdout || '[]') }
})
return { entries: [], ...read }
}
async function readTimestampsOverWindow(reader, { filter, window }) {
const reads = []
for (const subWindow of splitWindow(window)) {
const read = await readLogEntries(reader, {
filter: `${filter} AND ${timestampBounds(subWindow)}`,
projection: 'json(timestamp)'
})
reads.push({
failed: read.failed,
timestamps: read.entries.map((entry) => entry.timestamp)
})
}
return countByMinute(reads)
}
// Cloud Run's own request counter, aligned per minute server-side: a brownout's tens of thousands
// of 503s are counted, where a log read of them stops at its entry limit (10-01 c29).
async function readDirector503PerMinute(reader, { config, span }) {
const url = new URL(`https://monitoring.googleapis.com/v3/projects/${config.projectId}/timeSeries`)
url.searchParams.set('filter', [
'metric.type="run.googleapis.com/request_count"',
'resource.type="cloud_run_revision"',
`resource.label."service_name"="${config.directorService}"`,
'metric.label."response_code"="503"'
].join(' AND '))
url.searchParams.set('interval.startTime', span.startedAt.toISOString())
url.searchParams.set('interval.endTime', span.endedAt.toISOString())
url.searchParams.set('aggregation.alignmentPeriod', '60s')
url.searchParams.set('aggregation.perSeriesAligner', 'ALIGN_DELTA')
url.searchParams.set('aggregation.crossSeriesReducer', 'REDUCE_SUM')
url.searchParams.set('pageSize', '1000')
const read = await retryingRead(reader, async (timeoutMs) => {
const { stdout } = await reader.runGcloud(['auth', 'print-access-token'], { timeoutMs })
const response = await reader.fetch(url, {
headers: { authorization: `Bearer ${stdout.trim()}` },
signal: AbortSignal.timeout(timeoutMs)
})
if (!response.ok) throw new Error(`Cloud Monitoring returned ${response.status}`)
const body = await response.json()
if (body.nextPageToken) throw new Error('Cloud Monitoring pagination is incomplete')
return { timeSeries: body.timeSeries ?? [] }
})
const perMinute = {}
for (const series of read.timeSeries ?? []) {
for (const point of series.points ?? []) {
// A delta point covers the minute that ends at its end time; a minute with no point had none.
const minute = minuteKey(Date.parse(point.interval.endTime) - 60_000)
perMinute[minute] = (perMinute[minute] ?? 0) + Number(point.value.int64Value ?? 0)
}
}
return { perMinute, failed: read.failed }
}
// Cells log through the COS container agent, so the text lives in jsonPayload.message; a
// textPayload filter matches nothing here and returns zero without saying so.
const CELL_LOG_SCOPE = 'resource.type="gce_instance" AND logName:"cos_containers"'
const DIRECTOR_DRAIN_FIELDS = [
'stickyRejectionsByReasonDelta',
'placementRejectionsByReasonDelta',
'drainReturnDeferralsDelta',
'drainReturnAssignmentsDelta',
'drainReturnRetryAfterSecondsMax',
'assign503sByCauseDelta'
]
// Five instances at one sample per 30 s is ~100 per 10-min sub-window; this many is truncation.
const DIRECTOR_METRICS_LIMIT = 1000
async function readDirectorDrainReturn(reader, { config, window }) {
const projection = `json(timestamp,${DIRECTOR_DRAIN_FIELDS
.map((field) => `jsonPayload.${field}`)
.join(',')})`
const reads = []
for (const subWindow of splitWindow(window)) {
const read = await readLogEntries(reader, {
filter: `resource.type="cloud_run_revision"`
+ ` AND resource.labels.service_name="${config.directorService}"`
+ ` AND jsonPayload.event="orca_relay_runtime_metrics"`
+ ` AND ${timestampBounds(subWindow)}`,
projection,
limit: DIRECTOR_METRICS_LIMIT
})
reads.push({
failed: read.failed,
samples: read.entries.map((entry) => ({ timestamp: entry.timestamp, ...entry.jsonPayload })),
minSamples: Math.floor(
(subWindow.endedAt.getTime() - subWindow.startedAt.getTime()) / DIRECTOR_METRICS_INTERVAL_MS
)
})
}
return reads
}
// Non-drain 503s in the roll window against the same day's minutes just before the drain, with
// drain-return deferrals taken out of both.
async function readDirector503(reader, { config, window }) {
const minute = 60_000
const span = {
startedAt: new Date(
Math.floor(window.startedAt.getTime() / minute) * minute - BACKGROUND_MINUTES * minute
),
endedAt: new Date(Math.ceil(window.endedAt.getTime() / minute) * minute)
}
const counts = await readDirector503PerMinute(reader, { config, span })
const reads = await readDirectorDrainReturn(reader, { config, window: span })
const deferrals = drainReturnByMinute(reads, DIRECTOR_METRICS_LIMIT)
const windowMinutes = minutesOf(window)
const backgroundMinutes = minutesOf({ startedAt: span.startedAt, endedAt: window.startedAt })
.filter((key) => !windowMinutes.includes(key))
const observed = withoutDrainDeferrals(counts, deferrals, windowMinutes)
const background = backgroundOf(withoutDrainDeferrals(counts, deferrals, backgroundMinutes))
// Only the roll window's own samples describe this drain; whether the reads were whole is a
// question about all of them.
const drain = {
...drainReturnByMinute(reads.map((read) => ({
failed: read.failed,
samples: read.samples.filter(
(sample) => Date.parse(sample.timestamp) > window.startedAt.getTime()
)
})), DIRECTOR_METRICS_LIMIT),
truncated: deferrals.truncated
}
return {
background,
checks: {
nonDrain503Budget: judgeNonDrain503Budget({ observed, background }),
drainDeferrals: judgeDrainDeferrals(drain)
}
}
}
/**
* The cell's new container. The listener announcement after the apply identifies both that the
* cell is serving and which instance it is serving on; crashes are then scoped to that instance,
* because instance_id is stable across a container restart and is the only cell label these
* entries carry.
*/
async function readCellServing(reader, { config, window, searchFrom, expectBoot }) {
const listening = await readLogEntries(reader, {
filter: `${CELL_LOG_SCOPE}`
+ ` AND jsonPayload.message:"listening on https://${config.cellHost}"`
+ ` AND ${timestampBounds({ startedAt: searchFrom, endedAt: window.endedAt })}`,
projection: 'json(timestamp,resource.labels.instance_id)',
limit: 50
})
// Newest first: the most recent announcement is the boot this wave produced.
const boot = listening.entries[0]
if (listening.failed || !boot) {
return {
serving: judgeCellServing({ listeningAt: null, read: listening, expectBoot }),
instanceId: null
}
}
const crashes = await readLogEntries(reader, {
filter: `${CELL_LOG_SCOPE}`
+ ` AND jsonPayload.message:"throw er"`
+ ` AND resource.labels.instance_id="${boot.resource.labels.instance_id}"`
+ ` AND ${timestampBounds({ startedAt: searchFrom, endedAt: window.endedAt })}`,
projection: 'json(timestamp)',
limit: 100
})
return {
serving: judgeCellServing({
listeningAt: boot.timestamp,
crashesSinceApply: crashes.entries.length,
read: crashes,
expectBoot
}),
instanceId: boot.resource.labels.instance_id
}
}
const RUNTIME_METRIC_FIELDS = [
'totalConnections',
'databasePoolWaitersMax',
'databasePoolWaiting',
'sqlFailuresDelta',
'reconnectsDelta'
]
async function readRuntimeMetrics(reader, { cellId, window }) {
const projection = `json(timestamp,${RUNTIME_METRIC_FIELDS
.map((field) => `jsonPayload.${field}`)
.join(',')})`
const samples = []
let failed = false
let truncated = false
// Samples land every 30 s, so a 10-minute sub-window holds ~20. A read that comes back at this
// many is not a calm sub-window, it is a truncated one, and its gaps read as recoveries.
const limit = 500
for (const subWindow of splitWindow(window)) {
const read = await readLogEntries(reader, {
filter: `${CELL_LOG_SCOPE}`
+ ` AND jsonPayload.event="orca_relay_runtime_metrics"`
+ ` AND jsonPayload.cellId="${cellId}"`
+ ` AND ${timestampBounds(subWindow)}`,
projection,
limit
})
if (read.failed) failed = true
if (read.entries.length >= limit) truncated = true
for (const entry of read.entries) {
samples.push({ timestamp: entry.timestamp, ...entry.jsonPayload })
}
}
return { samples, failed, truncated }
}
async function readCloudSqlFatal(reader, { window }) {
const counts = await readTimestampsOverWindow(reader, {
filter: `resource.type="cloudsql_database" AND "FATAL"`,
window
})
return judgeCloudSqlFatal({ count: counts.total, truncated: counts.truncated })
}
export async function evaluateShadowGate(config, {
runGcloud,
retryDelayMs = READ_RETRY_DELAY_MS,
readTimeoutMs = READ_TIMEOUT_MS,
overallDeadlineMs = OVERALL_DEADLINE_MS,
now = Date.now,
fetch = globalThis.fetch
}) {
const reader = {
runGcloud,
retryDelayMs,
readTimeoutMs,
now,
deadlineAt: now() + overallDeadlineMs,
projectId: config.projectId,
fetch
}
const window = resolveWindow(config)
// Everything this roll's instance logged, from the moment the apply could first restart it.
const searchFrom = config.applyStartedAt
? new Date(Date.parse(config.applyStartedAt))
: window.startedAt
// Serialised on purpose: a burst of concurrent reads is what earns a Logging 429, and a 429 is
// the one failure that comes back as a short answer rather than an error.
const director = await readDirector503(reader, { config, window })
// A fallback window start means neither the drain nor the apply ran, which is the resumed
// rollback that restarts nothing; there is then no boot to find.
const cell = await readCellServing(reader, {
config,
window,
searchFrom,
expectBoot: window.startedFrom !== 'fallback'
})
const cloudSql = await readCloudSqlFatal(reader, { window })
const cellMetrics = await readRuntimeMetrics(reader, { cellId: config.cellId, window })
const checks = {
...director.checks,
cellServing: cell.serving,
cellPool: judgePool({ label: config.cellId, ...cellMetrics }),
cloudSqlFatal: cloudSql
}
for (const fleetCellId of FLEET_POOL_CELL_IDS) {
if (fleetCellId === config.cellId) continue
const metrics = await readRuntimeMetrics(reader, { cellId: fleetCellId, window })
checks[`fleetPool:${fleetCellId}`] = judgePool({ label: fleetCellId, ...metrics })
}
return {
reportOnly: true,
cellId: config.cellId,
cellHost: config.cellHost,
cellInstanceId: cell.instanceId,
window: {
startedAt: formatTimestamp(window.startedAt),
endedAt: formatTimestamp(window.endedAt),
startedFrom: window.startedFrom,
// Recorded, not judged: an operator comparing verdicts needs to see how long the apply took
// next to when the cell actually came back.
applyCompletedAt: config.applyCompletedAt || null
},
// The ladder rung this roll ran at, and what it measured against, for the step's record.
drain: {
paceWindowMs: config.drainPaceWindowMs,
appliedPaceWindowMs: config.drainAppliedPaceWindowMs,
targetHosts: config.targetHosts,
settledAt: config.drainSettledAt || null,
// Isolate to restart-safe: the later of the pace window's end and the cell emptying, plus
// one 5 s sample.
settledAfterSeconds: config.drainStartedAt && config.drainSettledAt
? Math.round((Date.parse(config.drainSettledAt) - Date.parse(config.drainStartedAt)) / 1000)
: null
},
// The pre-drain minutes the 503 checks were judged against.
background: director.background,
verdict: combineVerdict(checks),
// What a canary seals and a faster batch requires: only the checks the pace can move.
paceVerdict: combineVerdict(Object.fromEntries(PACE_CHECKS.map((name) => [name, checks[name]]))),
checks
}
}
async function main() {
const config = parseShadowGateArguments(process.argv.slice(2))
const report = await evaluateShadowGate(config, {
// `timeout` makes Node kill the child itself; continue-on-error bounds the job's outcome but
// not its clock, and a stalled read would otherwise spend the rollout's remaining minutes.
runGcloud: (args, { timeoutMs }) => execFileAsync('gcloud', args, {
maxBuffer: READ_MAX_BUFFER_BYTES,
timeout: timeoutMs,
killSignal: 'SIGKILL'
})
})
await writeFile(config.outputFile, `${JSON.stringify(report, null, 2)}\n`)
if (config.summaryFile) await appendFile(config.summaryFile, renderStepSummary(report))
console.log(JSON.stringify(report, null, 2))
}
// Report only: a verdict, including WOULD_BLOCK, is a successful run. Only a crash exits non-zero,
// and the job still runs this step under continue-on-error.
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch((error) => {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`)
process.exitCode = 1
})
}