mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
fix(relay-ops): retry transient admin-endpoint failures in same-cap verify and rehome jobs (#18769)
This commit is contained in:
@@ -275,10 +275,22 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
ORCA_RELAY_ADMIN_ID_TOKEN: ${{ steps.deploy-auth.outputs.id_token }}
|
ORCA_RELAY_ADMIN_ID_TOKEN: ${{ steps.deploy-auth.outputs.id_token }}
|
||||||
run: |
|
run: |
|
||||||
CURRENT_RUNTIME="$(curl --fail-with-body --max-time 30 \
|
# A single transient 5xx (LB warm-up behind a fresh instance) must not
|
||||||
--request POST "${CELL_ORIGIN}/v1/admin/runtime-status" \
|
# fail a canary; 4xx (auth, generation mismatch) still fails fast.
|
||||||
--header "Authorization: Bearer ${ORCA_RELAY_ADMIN_ID_TOKEN}" \
|
admin_post() {
|
||||||
--header 'Content-Type: application/json' --data '{"v":1}')"
|
local out="${RUNNER_TEMP}/$1.json"
|
||||||
|
if ! curl --fail-with-body --max-time 30 \
|
||||||
|
--retry 3 --retry-delay 2 --retry-connrefused --output "${out}" \
|
||||||
|
--request POST "$2" \
|
||||||
|
--header "Authorization: Bearer ${ORCA_RELAY_ADMIN_ID_TOKEN}" \
|
||||||
|
--header 'Content-Type: application/json' --data "$3"; then
|
||||||
|
cat "${out}" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
cat "${out}"
|
||||||
|
}
|
||||||
|
CURRENT_RUNTIME="$(admin_post current-runtime \
|
||||||
|
"${CELL_ORIGIN}/v1/admin/runtime-status" '{"v":1}')"
|
||||||
# A rollback that failed between template apply and admission restore
|
# A rollback that failed between template apply and admission restore
|
||||||
# leaves the cell already on the rollback image; resume from that
|
# leaves the cell already on the rollback image; resume from that
|
||||||
# state instead of demanding the pre-rollback predecessor.
|
# state instead of demanding the pre-rollback predecessor.
|
||||||
@@ -374,11 +386,9 @@ jobs:
|
|||||||
if .regionalRehomeProtocol == null then "regionalRehomeProtocol" else empty end
|
if .regionalRehomeProtocol == null then "regionalRehomeProtocol" else empty end
|
||||||
] | if length > 0 then "runtime predecessor normalized legacy fields=" + join(",") else empty end' \
|
] | if length > 0 then "runtime predecessor normalized legacy fields=" + join(",") else empty end' \
|
||||||
<<< "${CURRENT_RUNTIME}"
|
<<< "${CURRENT_RUNTIME}"
|
||||||
CURRENT_DIRECTOR_STATUS="$(curl --fail-with-body --max-time 30 \
|
CURRENT_DIRECTOR_STATUS="$(admin_post current-cell-status \
|
||||||
--request POST "${DIRECTOR_ORIGIN}/v1/admin/cell-status" \
|
"${DIRECTOR_ORIGIN}/v1/admin/cell-status" \
|
||||||
--header "Authorization: Bearer ${ORCA_RELAY_ADMIN_ID_TOKEN}" \
|
"$(jq -cn --arg cell "${TARGET_CELL_ID}" '{v:1,cellId:$cell}')")"
|
||||||
--header 'Content-Type: application/json' \
|
|
||||||
--data "$(jq -cn --arg cell "${TARGET_CELL_ID}" '{v:1,cellId:$cell}')")"
|
|
||||||
SOURCE_INCARNATION="$(jq -er '.status.runtime.cellIncarnation' \
|
SOURCE_INCARNATION="$(jq -er '.status.runtime.cellIncarnation' \
|
||||||
<<< "${CURRENT_DIRECTOR_STATUS}")"
|
<<< "${CURRENT_DIRECTOR_STATUS}")"
|
||||||
if test "${ROLLBACK_RESUME}" = true && ! jq -e \
|
if test "${ROLLBACK_RESUME}" = true && ! jq -e \
|
||||||
@@ -536,6 +546,20 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
ORCA_RELAY_ADMIN_ID_TOKEN: ${{ steps.post-auth.outputs.id_token }}
|
ORCA_RELAY_ADMIN_ID_TOKEN: ${{ steps.post-auth.outputs.id_token }}
|
||||||
run: |
|
run: |
|
||||||
|
# A single transient 5xx (LB warm-up behind a fresh instance) must not
|
||||||
|
# fail a canary; 4xx (auth, generation mismatch) still fails fast.
|
||||||
|
admin_post() {
|
||||||
|
local out="${RUNNER_TEMP}/$1.json"
|
||||||
|
if ! curl --fail-with-body --max-time 30 \
|
||||||
|
--retry 3 --retry-delay 2 --retry-connrefused --output "${out}" \
|
||||||
|
--request POST "$2" \
|
||||||
|
--header "Authorization: Bearer ${ORCA_RELAY_ADMIN_ID_TOKEN}" \
|
||||||
|
--header 'Content-Type: application/json' --data "$3"; then
|
||||||
|
cat "${out}" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
cat "${out}"
|
||||||
|
}
|
||||||
node dev/scripts/verify-relay-capacity-transition.mjs \
|
node dev/scripts/verify-relay-capacity-transition.mjs \
|
||||||
--director-origin "${DIRECTOR_ORIGIN}" --cell-origin "${CELL_ORIGIN}" \
|
--director-origin "${DIRECTOR_ORIGIN}" --cell-origin "${CELL_ORIGIN}" \
|
||||||
--cell-id "${TARGET_CELL_ID}" --hard-cap "${EXPECTED_HARD_CAP}" \
|
--cell-id "${TARGET_CELL_ID}" --hard-cap "${EXPECTED_HARD_CAP}" \
|
||||||
@@ -543,19 +567,15 @@ jobs:
|
|||||||
--heartbeat fresh --admission migration-only --draining forbidden \
|
--heartbeat fresh --admission migration-only --draining forbidden \
|
||||||
--activity allowed --expected-image-digests "${DESIRED_IMAGE_DIGEST}" \
|
--activity allowed --expected-image-digests "${DESIRED_IMAGE_DIGEST}" \
|
||||||
--regional-rehome-protocol "${DESIRED_REHOME_PROTOCOL}" --timeout-ms 900000
|
--regional-rehome-protocol "${DESIRED_REHOME_PROTOCOL}" --timeout-ms 900000
|
||||||
TARGET_RUNTIME="$(curl --fail-with-body --max-time 30 \
|
TARGET_RUNTIME="$(admin_post target-runtime \
|
||||||
--request POST "${CELL_ORIGIN}/v1/admin/runtime-status" \
|
"${CELL_ORIGIN}/v1/admin/runtime-status" '{"v":1}')"
|
||||||
--header "Authorization: Bearer ${ORCA_RELAY_ADMIN_ID_TOKEN}" \
|
|
||||||
--header 'Content-Type: application/json' --data '{"v":1}')"
|
|
||||||
jq -e --arg digest "${DESIRED_IMAGE_DIGEST}" \
|
jq -e --arg digest "${DESIRED_IMAGE_DIGEST}" \
|
||||||
--argjson protocol "${DESIRED_REHOME_PROTOCOL}" \
|
--argjson protocol "${DESIRED_REHOME_PROTOCOL}" \
|
||||||
'.imageDigest == $digest and (.regionalRehomeProtocol // 0) == $protocol' \
|
'.imageDigest == $digest and (.regionalRehomeProtocol // 0) == $protocol' \
|
||||||
<<< "${TARGET_RUNTIME}" >/dev/null
|
<<< "${TARGET_RUNTIME}" >/dev/null
|
||||||
TARGET_DIRECTOR_STATUS="$(curl --fail-with-body --max-time 30 \
|
TARGET_DIRECTOR_STATUS="$(admin_post target-cell-status \
|
||||||
--request POST "${DIRECTOR_ORIGIN}/v1/admin/cell-status" \
|
"${DIRECTOR_ORIGIN}/v1/admin/cell-status" \
|
||||||
--header "Authorization: Bearer ${ORCA_RELAY_ADMIN_ID_TOKEN}" \
|
"$(jq -cn --arg cell "${TARGET_CELL_ID}" '{v:1,cellId:$cell}')")"
|
||||||
--header 'Content-Type: application/json' \
|
|
||||||
--data "$(jq -cn --arg cell "${TARGET_CELL_ID}" '{v:1,cellId:$cell}')")"
|
|
||||||
TARGET_INCARNATION="$(jq -er '.status.runtime.cellIncarnation' \
|
TARGET_INCARNATION="$(jq -er '.status.runtime.cellIncarnation' \
|
||||||
<<< "${TARGET_DIRECTOR_STATUS}")"
|
<<< "${TARGET_DIRECTOR_STATUS}")"
|
||||||
if test "${ROLLBACK_RESUME}" = true; then
|
if test "${ROLLBACK_RESUME}" = true; then
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { pathToFileURL } from 'node:url'
|
import { pathToFileURL } from 'node:url'
|
||||||
|
import { fetchAdminOnceMore } from './relay-admin-transient-retry.mjs'
|
||||||
import { inspectAdmissionSelector } from './relay-admission-selector.mjs'
|
import { inspectAdmissionSelector } from './relay-admission-selector.mjs'
|
||||||
|
|
||||||
const DIRECTOR_ORIGIN = 'https://relay.onorca.dev'
|
const DIRECTOR_ORIGIN = 'https://relay.onorca.dev'
|
||||||
@@ -229,15 +230,20 @@ export async function recoverRegionalRehomeEnable(config, post) {
|
|||||||
export async function operateRegionalRehome(config, dependencies = {}) {
|
export async function operateRegionalRehome(config, dependencies = {}) {
|
||||||
const fetchImpl = dependencies.fetch ?? fetch
|
const fetchImpl = dependencies.fetch ?? fetch
|
||||||
const post = dependencies.post ?? (async (path, body) => await responseJson(
|
const post = dependencies.post ?? (async (path, body) => await responseJson(
|
||||||
await fetchImpl(`${config.directorOrigin}${path}`, {
|
// Generation-guarded writes make a retry a no-op or an explicit mismatch, never a double apply.
|
||||||
method: 'POST',
|
await fetchAdminOnceMore(
|
||||||
headers: {
|
fetchImpl,
|
||||||
authorization: `Bearer ${config.token}`,
|
`${config.directorOrigin}${path}`,
|
||||||
'content-type': 'application/json'
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${config.token}`,
|
||||||
|
'content-type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body)
|
||||||
},
|
},
|
||||||
body: JSON.stringify(body),
|
{ wait: dependencies.wait }
|
||||||
signal: AbortSignal.timeout(30_000)
|
),
|
||||||
}),
|
|
||||||
path
|
path
|
||||||
))
|
))
|
||||||
if (config.mode === 'recover-enable') {
|
if (config.mode === 'recover-enable') {
|
||||||
|
|||||||
@@ -263,3 +263,55 @@ test('main executes recovery mode and emits verified disabled control', async ()
|
|||||||
control: control(6, false)
|
control: control(6, false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('retries a transient 503 on the director control endpoint', async () => {
|
||||||
|
const config = parseRegionalRehomeArguments(
|
||||||
|
argumentsFor('inspect'),
|
||||||
|
{ ORCA_RELAY_ADMIN_ID_TOKEN: 'token' }
|
||||||
|
)
|
||||||
|
const paths = []
|
||||||
|
let selectorCalls = 0
|
||||||
|
const result = await operateRegionalRehome(config, {
|
||||||
|
wait: async () => {},
|
||||||
|
fetch: async (url) => {
|
||||||
|
const path = new URL(url).pathname
|
||||||
|
paths.push(path)
|
||||||
|
if (path === '/v1/admin/admission-selector/status') {
|
||||||
|
selectorCalls += 1
|
||||||
|
// The first read of each admin path 503s the way a warming instance does.
|
||||||
|
if (selectorCalls === 1) return new Response('warming up', { status: 503 })
|
||||||
|
return Response.json({ selector: { generation: 11, membership } })
|
||||||
|
}
|
||||||
|
if (paths.filter((value) => value === path).length === 1) {
|
||||||
|
return new Response('warming up', { status: 503 })
|
||||||
|
}
|
||||||
|
return Response.json({ v: 1, control: control(4, false) })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
assert.equal(result.control.generation, 4)
|
||||||
|
assert.deepEqual(paths, [
|
||||||
|
'/v1/admin/admission-selector/status',
|
||||||
|
'/v1/admin/admission-selector/status',
|
||||||
|
'/v1/admin/regional-rehome-control',
|
||||||
|
'/v1/admin/regional-rehome-control'
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('fails when both attempts at the director control endpoint return 503', async () => {
|
||||||
|
const config = parseRegionalRehomeArguments(
|
||||||
|
argumentsFor('inspect'),
|
||||||
|
{ ORCA_RELAY_ADMIN_ID_TOKEN: 'token' }
|
||||||
|
)
|
||||||
|
let calls = 0
|
||||||
|
await assert.rejects(
|
||||||
|
operateRegionalRehome(config, {
|
||||||
|
wait: async () => {},
|
||||||
|
fetch: async () => {
|
||||||
|
calls += 1
|
||||||
|
return new Response('warming up', { status: 503 })
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
/returned 503/
|
||||||
|
)
|
||||||
|
assert.equal(calls, 2)
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { pathToFileURL } from 'node:url'
|
import { pathToFileURL } from 'node:url'
|
||||||
|
import { fetchAdminOnceMore } from './relay-admin-transient-retry.mjs'
|
||||||
import {
|
import {
|
||||||
applyExactAdmissionSelector,
|
applyExactAdmissionSelector,
|
||||||
inspectAdmissionSelector,
|
inspectAdmissionSelector,
|
||||||
@@ -72,12 +73,16 @@ export async function prepareProductionCapacityCell(config, overrides = {}) {
|
|||||||
if (!token || token.length > 8_192) throw new Error('admin identity token is unavailable')
|
if (!token || token.length > 8_192) throw new Error('admin identity token is unavailable')
|
||||||
const postAt = async (origin, path, body) =>
|
const postAt = async (origin, path, body) =>
|
||||||
await responseJson(
|
await responseJson(
|
||||||
await fetchImpl(`${origin}${path}`, {
|
await fetchAdminOnceMore(
|
||||||
method: 'POST',
|
fetchImpl,
|
||||||
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
|
`${origin}${path}`,
|
||||||
body: JSON.stringify(body),
|
{
|
||||||
signal: AbortSignal.timeout(30_000)
|
method: 'POST',
|
||||||
}),
|
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
},
|
||||||
|
{ wait: overrides.wait }
|
||||||
|
),
|
||||||
path
|
path
|
||||||
)
|
)
|
||||||
const post = async (path, body) => await postAt(config.directorOrigin, path, body)
|
const post = async (path, body) => await postAt(config.directorOrigin, path, body)
|
||||||
|
|||||||
@@ -170,4 +170,42 @@ describe('production Relay capacity cell admission', () => {
|
|||||||
/irreversible/
|
/irreversible/
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('retries a transient 503 on the cell drain endpoint', async () => {
|
||||||
|
let calls = 0
|
||||||
|
const result = await prepareProductionCapacityCell(
|
||||||
|
{ ...config, mode: 'drain' },
|
||||||
|
{
|
||||||
|
token: 'token',
|
||||||
|
wait: async () => {},
|
||||||
|
fetch: async (url) => {
|
||||||
|
assert.equal(new URL(url).pathname, '/v1/admin/drain')
|
||||||
|
calls += 1
|
||||||
|
if (calls === 1) return response({ error: 'warming up' }, 503)
|
||||||
|
return response({ v: 1, draining: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert.equal(calls, 2)
|
||||||
|
assert.deepEqual(result, { changed: false, drained: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fails when both drain attempts return a transient 503', async () => {
|
||||||
|
let calls = 0
|
||||||
|
await assert.rejects(
|
||||||
|
prepareProductionCapacityCell(
|
||||||
|
{ ...config, mode: 'drain' },
|
||||||
|
{
|
||||||
|
token: 'token',
|
||||||
|
wait: async () => {},
|
||||||
|
fetch: async () => {
|
||||||
|
calls += 1
|
||||||
|
return response({ error: 'warming up' }, 503)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
),
|
||||||
|
/returned 503/
|
||||||
|
)
|
||||||
|
assert.equal(calls, 2)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { pathToFileURL } from 'node:url'
|
import { pathToFileURL } from 'node:url'
|
||||||
|
import { fetchAdminOnceMore } from './relay-admin-transient-retry.mjs'
|
||||||
|
|
||||||
const PRODUCTION_CELL = /^production-gce-c(?:7|8|9|10|13|14|15|16|19|20|21|22|23|24|25|26)$/
|
const PRODUCTION_CELL = /^production-gce-c(?:7|8|9|10|13|14|15|16|19|20|21|22|23|24|25|26)$/
|
||||||
const DIRECTOR_ORIGIN = 'https://relay.onorca.dev'
|
const DIRECTOR_ORIGIN = 'https://relay.onorca.dev'
|
||||||
@@ -35,7 +36,8 @@ export function parseRehomeTrustProbeArguments(argv, environment = process.env)
|
|||||||
|
|
||||||
export async function probeRehomeTrust(config, dependencies = {}) {
|
export async function probeRehomeTrust(config, dependencies = {}) {
|
||||||
const fetchImpl = dependencies.fetch ?? fetch
|
const fetchImpl = dependencies.fetch ?? fetch
|
||||||
const response = await fetchImpl(
|
const response = await fetchAdminOnceMore(
|
||||||
|
fetchImpl,
|
||||||
`${config.directorOrigin}/v1/admin/regional-rehome-trust-probe`,
|
`${config.directorOrigin}/v1/admin/regional-rehome-trust-probe`,
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -47,9 +49,9 @@ export async function probeRehomeTrust(config, dependencies = {}) {
|
|||||||
v: 1,
|
v: 1,
|
||||||
sourceCellId: config.cellId,
|
sourceCellId: config.cellId,
|
||||||
sourceCellIncarnation: config.cellIncarnation
|
sourceCellIncarnation: config.cellIncarnation
|
||||||
}),
|
})
|
||||||
signal: AbortSignal.timeout(30_000)
|
},
|
||||||
}
|
{ wait: dependencies.wait }
|
||||||
)
|
)
|
||||||
const body = await response.json().catch(() => ({}))
|
const body = await response.json().catch(() => ({}))
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
@@ -68,3 +68,46 @@ test('rejects partial or mismatched proof', async () => {
|
|||||||
/incomplete/
|
/incomplete/
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const provenProbe = {
|
||||||
|
v: 1,
|
||||||
|
dedicatedIdentity: {
|
||||||
|
firstOutcome: 'host-not-connected',
|
||||||
|
secondOutcome: 'host-not-connected',
|
||||||
|
accepted: true,
|
||||||
|
idempotent: true
|
||||||
|
},
|
||||||
|
sharedRuntimeIdentityRejected: true,
|
||||||
|
proven: true
|
||||||
|
}
|
||||||
|
|
||||||
|
test('retries a transient 503 on the trust probe and proves on the second answer', async () => {
|
||||||
|
const config = parseRehomeTrustProbeArguments(argv, environment)
|
||||||
|
let calls = 0
|
||||||
|
const result = await probeRehomeTrust(config, {
|
||||||
|
wait: async () => {},
|
||||||
|
fetch: async () => {
|
||||||
|
calls += 1
|
||||||
|
if (calls === 1) return new Response('warming up', { status: 503 })
|
||||||
|
return Response.json(provenProbe)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
assert.equal(calls, 2)
|
||||||
|
assert.equal(result.proven, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('fails when both trust-probe attempts return a transient 503', async () => {
|
||||||
|
const config = parseRehomeTrustProbeArguments(argv, environment)
|
||||||
|
let calls = 0
|
||||||
|
await assert.rejects(
|
||||||
|
probeRehomeTrust(config, {
|
||||||
|
wait: async () => {},
|
||||||
|
fetch: async () => {
|
||||||
|
calls += 1
|
||||||
|
return new Response('warming up', { status: 503 })
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
/returned 503/
|
||||||
|
)
|
||||||
|
assert.equal(calls, 2)
|
||||||
|
})
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { test } from 'node:test'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { relayWorkflowUrl } from './relay-repository.mjs'
|
||||||
|
|
||||||
|
const WORKFLOWS = [
|
||||||
|
'deploy-relay-production-same-cap-job.yml',
|
||||||
|
'operate-relay-production-rehome-job.yml'
|
||||||
|
]
|
||||||
|
|
||||||
|
function workflow(name) {
|
||||||
|
return readFileSync(fileURLToPath(relayWorkflowUrl(name)), 'utf8')
|
||||||
|
}
|
||||||
|
|
||||||
|
// A single transient 5xx from a warming instance behind the global load balancer
|
||||||
|
// must not fail a canary, so no admin endpoint may be read by a bare curl.
|
||||||
|
test('no admin endpoint is reached by a curl without a bounded retry', () => {
|
||||||
|
for (const name of WORKFLOWS) {
|
||||||
|
for (const invocation of workflow(name).split(/\bcurl\b/).slice(1)) {
|
||||||
|
const flags = invocation.split('\n }')[0]
|
||||||
|
assert.match(flags, /--retry 3 --retry-delay 2 --retry-connrefused/, name)
|
||||||
|
assert.match(flags, /--max-time 30/, name)
|
||||||
|
// --retry-all-errors would also retry 401, 403, and 409, which are final.
|
||||||
|
assert.doesNotMatch(flags, /--retry-all-errors/, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('every retried admin request captures only the final attempt body', () => {
|
||||||
|
const job = workflow('deploy-relay-production-same-cap-job.yml')
|
||||||
|
// --fail-with-body writes every failed attempt to stdout, so a retried
|
||||||
|
// request must land in a file curl truncates per attempt.
|
||||||
|
assert.match(job, /--output "\$\{out\}"/)
|
||||||
|
assert.equal(job.split('admin_post() {').length - 1, 2)
|
||||||
|
for (const call of [
|
||||||
|
/CURRENT_RUNTIME="\$\(admin_post current-runtime/,
|
||||||
|
/CURRENT_DIRECTOR_STATUS="\$\(admin_post current-cell-status/,
|
||||||
|
/TARGET_RUNTIME="\$\(admin_post target-runtime/,
|
||||||
|
/TARGET_DIRECTOR_STATUS="\$\(admin_post target-cell-status/
|
||||||
|
]) assert.match(job, call)
|
||||||
|
assert.doesNotMatch(job, /\$\(curl /)
|
||||||
|
})
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// A single transient 5xx (load-balancer warm-up behind a fresh instance) must not fail a
|
||||||
|
// deploy step. 4xx is never retried: auth and generation-mismatch answers are final.
|
||||||
|
const TRANSIENT_STATUSES = [500, 502, 503, 504]
|
||||||
|
const RETRY_DELAY_MS = 2_000
|
||||||
|
const REQUEST_TIMEOUT_MS = 30_000
|
||||||
|
|
||||||
|
export function isTransientAdminStatus(status) {
|
||||||
|
return TRANSIENT_STATUSES.includes(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each attempt gets its own timeout budget, so a reused signal cannot abort the retry.
|
||||||
|
export async function fetchAdminOnceMore(fetchImpl, url, init, overrides = {}) {
|
||||||
|
const wait = overrides.wait ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)))
|
||||||
|
const timeoutMs = overrides.timeoutMs ?? REQUEST_TIMEOUT_MS
|
||||||
|
const retryDelayMs = overrides.retryDelayMs ?? RETRY_DELAY_MS
|
||||||
|
const attempt = async () =>
|
||||||
|
await fetchImpl(url, { ...init, signal: AbortSignal.timeout(timeoutMs) })
|
||||||
|
let response
|
||||||
|
try {
|
||||||
|
response = await attempt()
|
||||||
|
} catch {
|
||||||
|
await wait(retryDelayMs)
|
||||||
|
return await attempt()
|
||||||
|
}
|
||||||
|
if (!isTransientAdminStatus(response.status)) return response
|
||||||
|
await response.arrayBuffer?.().catch(() => undefined)
|
||||||
|
await wait(retryDelayMs)
|
||||||
|
return await attempt()
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { test } from 'node:test'
|
||||||
|
import { fetchAdminOnceMore } from './relay-admin-transient-retry.mjs'
|
||||||
|
|
||||||
|
const url = 'https://relay.onorca.dev/v1/admin/cell-status'
|
||||||
|
const init = { method: 'POST', body: '{"v":1}' }
|
||||||
|
|
||||||
|
function recordingWait(waits) {
|
||||||
|
return async (ms) => { waits.push(ms) }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a single transient 5xx is retried and the second answer is returned', async () => {
|
||||||
|
const waits = []
|
||||||
|
const statuses = [503, 200]
|
||||||
|
let calls = 0
|
||||||
|
const response = await fetchAdminOnceMore(
|
||||||
|
async () => {
|
||||||
|
calls += 1
|
||||||
|
const status = statuses.shift()
|
||||||
|
return new Response(JSON.stringify({ ok: status === 200 }), { status })
|
||||||
|
},
|
||||||
|
url,
|
||||||
|
init,
|
||||||
|
{ wait: recordingWait(waits) }
|
||||||
|
)
|
||||||
|
assert.equal(calls, 2)
|
||||||
|
assert.equal(response.status, 200)
|
||||||
|
assert.deepEqual(waits, [2_000])
|
||||||
|
assert.deepEqual(await response.json(), { ok: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a connection failure is retried and the second answer is returned', async () => {
|
||||||
|
const waits = []
|
||||||
|
let calls = 0
|
||||||
|
const response = await fetchAdminOnceMore(
|
||||||
|
async () => {
|
||||||
|
calls += 1
|
||||||
|
if (calls === 1) throw new TypeError('fetch failed')
|
||||||
|
return Response.json({ ok: true })
|
||||||
|
},
|
||||||
|
url,
|
||||||
|
init,
|
||||||
|
{ wait: recordingWait(waits) }
|
||||||
|
)
|
||||||
|
assert.equal(calls, 2)
|
||||||
|
assert.equal(response.status, 200)
|
||||||
|
assert.deepEqual(waits, [2_000])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('two transient failures surface the second answer without a third attempt', async () => {
|
||||||
|
let calls = 0
|
||||||
|
const response = await fetchAdminOnceMore(
|
||||||
|
async () => {
|
||||||
|
calls += 1
|
||||||
|
return new Response('down', { status: 503 })
|
||||||
|
},
|
||||||
|
url,
|
||||||
|
init,
|
||||||
|
{ wait: async () => {} }
|
||||||
|
)
|
||||||
|
assert.equal(calls, 2)
|
||||||
|
assert.equal(response.status, 503)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('two connection failures rethrow the second error', async () => {
|
||||||
|
let calls = 0
|
||||||
|
await assert.rejects(
|
||||||
|
fetchAdminOnceMore(
|
||||||
|
async () => {
|
||||||
|
calls += 1
|
||||||
|
throw new TypeError(`fetch failed ${calls}`)
|
||||||
|
},
|
||||||
|
url,
|
||||||
|
init,
|
||||||
|
{ wait: async () => {} }
|
||||||
|
),
|
||||||
|
/fetch failed 2/
|
||||||
|
)
|
||||||
|
assert.equal(calls, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('4xx is final: auth and generation-mismatch answers are never retried', async () => {
|
||||||
|
for (const status of [400, 401, 403, 404, 409, 429]) {
|
||||||
|
let calls = 0
|
||||||
|
const response = await fetchAdminOnceMore(
|
||||||
|
async () => {
|
||||||
|
calls += 1
|
||||||
|
return new Response('no', { status })
|
||||||
|
},
|
||||||
|
url,
|
||||||
|
init,
|
||||||
|
{ wait: async () => { throw new Error('must not wait') } }
|
||||||
|
)
|
||||||
|
assert.equal(calls, 1, `status ${status} must not be retried`)
|
||||||
|
assert.equal(response.status, status)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('each attempt carries its own unexpired timeout signal', async () => {
|
||||||
|
const signals = []
|
||||||
|
await fetchAdminOnceMore(
|
||||||
|
async (_url, attemptInit) => {
|
||||||
|
signals.push(attemptInit.signal)
|
||||||
|
return new Response('down', { status: 502 })
|
||||||
|
},
|
||||||
|
url,
|
||||||
|
init,
|
||||||
|
{ wait: async () => {}, timeoutMs: 30_000 }
|
||||||
|
)
|
||||||
|
assert.equal(signals.length, 2)
|
||||||
|
assert.notEqual(signals[0], signals[1])
|
||||||
|
assert.equal(signals[1].aborted, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the caller init is forwarded unchanged apart from the signal', async () => {
|
||||||
|
let seen
|
||||||
|
await fetchAdminOnceMore(
|
||||||
|
async (seenUrl, attemptInit) => {
|
||||||
|
seen = { seenUrl, attemptInit }
|
||||||
|
return Response.json({})
|
||||||
|
},
|
||||||
|
url,
|
||||||
|
{ method: 'POST', headers: { authorization: 'Bearer t' }, body: '{"v":1}' },
|
||||||
|
{ wait: async () => {} }
|
||||||
|
)
|
||||||
|
assert.equal(seen.seenUrl, url)
|
||||||
|
assert.equal(seen.attemptInit.method, 'POST')
|
||||||
|
assert.deepEqual(seen.attemptInit.headers, { authorization: 'Bearer t' })
|
||||||
|
assert.equal(seen.attemptInit.body, '{"v":1}')
|
||||||
|
})
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { pathToFileURL } from 'node:url'
|
import { pathToFileURL } from 'node:url'
|
||||||
|
import { fetchAdminOnceMore } from './relay-admin-transient-retry.mjs'
|
||||||
|
|
||||||
const CAPACITY_PROTOCOL = 2
|
const CAPACITY_PROTOCOL = 2
|
||||||
|
|
||||||
@@ -378,9 +379,12 @@ export async function verifyCapacityTransition(config, overrides = {}) {
|
|||||||
const token = overrides.token ?? process.env.ORCA_RELAY_ADMIN_ID_TOKEN
|
const token = overrides.token ?? process.env.ORCA_RELAY_ADMIN_ID_TOKEN
|
||||||
if (!token || token.length > 8_192) throw new Error('admin identity token is unavailable')
|
if (!token || token.length > 8_192) throw new Error('admin identity token is unavailable')
|
||||||
const health = await responseJson(
|
const health = await responseJson(
|
||||||
await fetchImpl(`${config.directorOrigin}/health`, {
|
await fetchAdminOnceMore(
|
||||||
signal: AbortSignal.timeout(15_000)
|
fetchImpl,
|
||||||
}),
|
`${config.directorOrigin}/health`,
|
||||||
|
{},
|
||||||
|
{ wait, timeoutMs: 15_000 }
|
||||||
|
),
|
||||||
'director health'
|
'director health'
|
||||||
)
|
)
|
||||||
if (health.ok !== true || health.connectionCapacityProtocol !== CAPACITY_PROTOCOL) {
|
if (health.ok !== true || health.connectionCapacityProtocol !== CAPACITY_PROTOCOL) {
|
||||||
@@ -394,12 +398,16 @@ export async function verifyCapacityTransition(config, overrides = {}) {
|
|||||||
lastObservation = { runtimeAvailable: runtime !== null }
|
lastObservation = { runtimeAvailable: runtime !== null }
|
||||||
if ((runtime === null) === (config.runtime === 'unavailable')) {
|
if ((runtime === null) === (config.runtime === 'unavailable')) {
|
||||||
const result = await responseJson(
|
const result = await responseJson(
|
||||||
await fetchImpl(`${config.directorOrigin}/v1/admin/cell-status`, {
|
await fetchAdminOnceMore(
|
||||||
method: 'POST',
|
fetchImpl,
|
||||||
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
|
`${config.directorOrigin}/v1/admin/cell-status`,
|
||||||
body: JSON.stringify({ v: 1, cellId: config.cellId }),
|
{
|
||||||
signal: AbortSignal.timeout(30_000)
|
method: 'POST',
|
||||||
}),
|
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ v: 1, cellId: config.cellId })
|
||||||
|
},
|
||||||
|
{ wait }
|
||||||
|
),
|
||||||
'cell status'
|
'cell status'
|
||||||
)
|
)
|
||||||
const status = result.status
|
const status = result.status
|
||||||
|
|||||||
@@ -1094,3 +1094,77 @@ test('does not retry a rejected cell admin token', async () => {
|
|||||||
)
|
)
|
||||||
assert.equal(waits, 0)
|
assert.equal(waits, 0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('retries a transient 503 on the director cell-status read', async () => {
|
||||||
|
const base = harness()
|
||||||
|
const statusCalls = []
|
||||||
|
const result = await verifyCapacityTransition(config, {
|
||||||
|
token: 'masked-token',
|
||||||
|
wait: async () => {},
|
||||||
|
fetch: async (url, options) => {
|
||||||
|
const path = new URL(url).pathname
|
||||||
|
if (path !== '/v1/admin/cell-status') return await base(url, options)
|
||||||
|
statusCalls.push(path)
|
||||||
|
if (statusCalls.length === 1) return new Response('warming up', { status: 503 })
|
||||||
|
return await base(url, options)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
assert.equal(statusCalls.length, 2)
|
||||||
|
assert.equal(result.cellId, config.cellId)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('fails when both director cell-status attempts return a transient 503', async () => {
|
||||||
|
const base = harness()
|
||||||
|
let statusCalls = 0
|
||||||
|
await assert.rejects(
|
||||||
|
verifyCapacityTransition(config, {
|
||||||
|
token: 'masked-token',
|
||||||
|
wait: async () => {},
|
||||||
|
fetch: async (url, options) => {
|
||||||
|
const path = new URL(url).pathname
|
||||||
|
if (path !== '/v1/admin/cell-status') return await base(url, options)
|
||||||
|
statusCalls += 1
|
||||||
|
return new Response('warming up', { status: 503 })
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
/cell status returned 503/
|
||||||
|
)
|
||||||
|
assert.equal(statusCalls, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('retries a transient 503 on the director health preflight', async () => {
|
||||||
|
const base = harness()
|
||||||
|
let healthCalls = 0
|
||||||
|
const result = await verifyCapacityTransition(config, {
|
||||||
|
token: 'masked-token',
|
||||||
|
wait: async () => {},
|
||||||
|
fetch: async (url, options) => {
|
||||||
|
const path = new URL(url).pathname
|
||||||
|
if (path !== '/health') return await base(url, options)
|
||||||
|
healthCalls += 1
|
||||||
|
if (healthCalls === 1) return new Response('warming up', { status: 503 })
|
||||||
|
return await base(url, options)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
assert.equal(healthCalls, 2)
|
||||||
|
assert.equal(result.cellId, config.cellId)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('fails when both director health attempts return a transient 503', async () => {
|
||||||
|
const base = harness()
|
||||||
|
let healthCalls = 0
|
||||||
|
await assert.rejects(
|
||||||
|
verifyCapacityTransition(config, {
|
||||||
|
token: 'masked-token',
|
||||||
|
wait: async () => {},
|
||||||
|
fetch: async (url, options) => {
|
||||||
|
const path = new URL(url).pathname
|
||||||
|
if (path !== '/health') return await base(url, options)
|
||||||
|
healthCalls += 1
|
||||||
|
return new Response('warming up', { status: 503 })
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
/director health returned 503/
|
||||||
|
)
|
||||||
|
assert.equal(healthCalls, 2)
|
||||||
|
})
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@
|
|||||||
"load:relay:recovery-gate": "node dev/scripts/run-relay-recovery-wave-gate.mjs",
|
"load:relay:recovery-gate": "node dev/scripts/run-relay-recovery-wave-gate.mjs",
|
||||||
"ops:relay": "pnpm --filter @orca-cloud/relay-ops dev",
|
"ops:relay": "pnpm --filter @orca-cloud/relay-ops dev",
|
||||||
"pretest": "node --test dev/scripts/capture-terraform-plan-baseline.test.mjs dev/scripts/operate-relay-asia-admission.test.mjs dev/scripts/prepare-relay-asia-director-cells.test.mjs dev/scripts/prepare-relay-asia-topology-input.test.mjs dev/scripts/production-cloud-sql-rollout-lock.test.mjs dev/scripts/read-relay-serving-regional-placement-version.test.mjs dev/scripts/relay-asia-admission-workflow.test.mjs dev/scripts/relay-asia-rollout-evidence.test.mjs dev/scripts/relay-asia-topology-workflow.test.mjs dev/scripts/relay-cloud-sql-connection-budget.test.mjs dev/scripts/relay-load-reader-evidence.test.mjs dev/scripts/relay-staging-deploy-identity.test.mjs dev/scripts/sanitize-relay-asia-admission-result.test.mjs dev/scripts/terraform-root-partition.test.mjs dev/scripts/validate-relay-asia-topology-plan.test.mjs ../.github/actions/cloud-sql-rollout-lease/action-contract.test.mjs ../.github/actions/cloud-sql-rollout-lease/storage-lease.test.mjs",
|
"pretest": "node --test dev/scripts/capture-terraform-plan-baseline.test.mjs dev/scripts/operate-relay-asia-admission.test.mjs dev/scripts/prepare-relay-asia-director-cells.test.mjs dev/scripts/prepare-relay-asia-topology-input.test.mjs dev/scripts/production-cloud-sql-rollout-lock.test.mjs dev/scripts/read-relay-serving-regional-placement-version.test.mjs dev/scripts/relay-asia-admission-workflow.test.mjs dev/scripts/relay-asia-rollout-evidence.test.mjs dev/scripts/relay-asia-topology-workflow.test.mjs dev/scripts/relay-cloud-sql-connection-budget.test.mjs dev/scripts/relay-load-reader-evidence.test.mjs dev/scripts/relay-staging-deploy-identity.test.mjs dev/scripts/sanitize-relay-asia-admission-result.test.mjs dev/scripts/terraform-root-partition.test.mjs dev/scripts/validate-relay-asia-topology-plan.test.mjs ../.github/actions/cloud-sql-rollout-lease/action-contract.test.mjs ../.github/actions/cloud-sql-rollout-lease/storage-lease.test.mjs",
|
||||||
"test": "pnpm -r test && node --test dev/scripts/classify-relay-production-capacity-director.test.mjs dev/scripts/classify-relay-staging-bootstrap.test.mjs dev/scripts/deploy-relay-blue-green.test.mjs dev/scripts/deploy-relay-gce-candidate.test.mjs dev/scripts/deploy-relay-gce-multi-target.test.mjs dev/scripts/github-smoke-token.test.mjs dev/scripts/infra.test.mjs dev/scripts/operate-relay-regional-rehome.test.mjs dev/scripts/power-staging-relay.test.mjs dev/scripts/prepare-relay-capacity-canary.test.mjs dev/scripts/prepare-relay-production-capacity-canary.test.mjs dev/scripts/probe-relay-legacy-admission.test.mjs dev/scripts/probe-relay-rehome-trust.test.mjs dev/scripts/production-cell-image-digest-consistency.test.mjs dev/scripts/read-relay-production-capacity-identity.test.mjs dev/scripts/relay-admission-selector.test.mjs dev/scripts/relay-gce-terraform-fence.test.mjs dev/scripts/relay-load-connection-failure.test.mjs dev/scripts/relay-load-control-peer.test.mjs dev/scripts/relay-load-director-capacity-gate.test.mjs dev/scripts/relay-load-model.test.mjs dev/scripts/relay-load-phase-barrier.test.mjs dev/scripts/relay-load-placement-boundary.test.mjs dev/scripts/relay-load-profile.test.mjs dev/scripts/relay-load-rebind-boundary.test.mjs dev/scripts/relay-load-region-behavior.test.mjs dev/scripts/relay-load-request-unit-boundary.test.mjs dev/scripts/relay-load-run-lifecycle.test.mjs dev/scripts/relay-monitor-evidence.test.mjs dev/scripts/relay-production-capacity-wave.test.mjs dev/scripts/relay-production-capacity-workflow.test.mjs dev/scripts/relay-production-identity-boundaries.test.mjs dev/scripts/relay-production-same-cap-wave.test.mjs dev/scripts/relay-public-workflow-contract.test.mjs dev/scripts/relay-recovery-wave-gate.test.mjs dev/scripts/relay-region-observation-evidence.test.mjs dev/scripts/relay-regional-rehome-workflow.test.mjs dev/scripts/relay-rehome-aggregate-evidence.test.mjs dev/scripts/relay-repository.test.mjs dev/scripts/relay-staging-c4-refresh-workflow.test.mjs dev/scripts/relay-staging-capacity-identity.test.mjs dev/scripts/staging-relay-apply-guard.test.mjs dev/scripts/validate-relay-capacity-plan.test.mjs dev/scripts/verify-relay-capacity-transition.test.mjs dev/scripts/verify-relay-legacy-bootstrap.test.mjs dev/scripts/workload-identity-attribute-conditions.test.mjs",
|
"test": "pnpm -r test && node --test dev/scripts/classify-relay-production-capacity-director.test.mjs dev/scripts/classify-relay-staging-bootstrap.test.mjs dev/scripts/deploy-relay-blue-green.test.mjs dev/scripts/deploy-relay-gce-candidate.test.mjs dev/scripts/deploy-relay-gce-multi-target.test.mjs dev/scripts/github-smoke-token.test.mjs dev/scripts/infra.test.mjs dev/scripts/operate-relay-regional-rehome.test.mjs dev/scripts/power-staging-relay.test.mjs dev/scripts/prepare-relay-capacity-canary.test.mjs dev/scripts/prepare-relay-production-capacity-canary.test.mjs dev/scripts/probe-relay-legacy-admission.test.mjs dev/scripts/probe-relay-rehome-trust.test.mjs dev/scripts/production-cell-image-digest-consistency.test.mjs dev/scripts/read-relay-production-capacity-identity.test.mjs dev/scripts/relay-admin-endpoint-retry-workflow.test.mjs dev/scripts/relay-admin-transient-retry.test.mjs dev/scripts/relay-admission-selector.test.mjs dev/scripts/relay-gce-terraform-fence.test.mjs dev/scripts/relay-load-connection-failure.test.mjs dev/scripts/relay-load-control-peer.test.mjs dev/scripts/relay-load-director-capacity-gate.test.mjs dev/scripts/relay-load-model.test.mjs dev/scripts/relay-load-phase-barrier.test.mjs dev/scripts/relay-load-placement-boundary.test.mjs dev/scripts/relay-load-profile.test.mjs dev/scripts/relay-load-rebind-boundary.test.mjs dev/scripts/relay-load-region-behavior.test.mjs dev/scripts/relay-load-request-unit-boundary.test.mjs dev/scripts/relay-load-run-lifecycle.test.mjs dev/scripts/relay-monitor-evidence.test.mjs dev/scripts/relay-production-capacity-wave.test.mjs dev/scripts/relay-production-capacity-workflow.test.mjs dev/scripts/relay-production-identity-boundaries.test.mjs dev/scripts/relay-production-same-cap-wave.test.mjs dev/scripts/relay-public-workflow-contract.test.mjs dev/scripts/relay-recovery-wave-gate.test.mjs dev/scripts/relay-region-observation-evidence.test.mjs dev/scripts/relay-regional-rehome-workflow.test.mjs dev/scripts/relay-rehome-aggregate-evidence.test.mjs dev/scripts/relay-repository.test.mjs dev/scripts/relay-staging-c4-refresh-workflow.test.mjs dev/scripts/relay-staging-capacity-identity.test.mjs dev/scripts/staging-relay-apply-guard.test.mjs dev/scripts/validate-relay-capacity-plan.test.mjs dev/scripts/verify-relay-capacity-transition.test.mjs dev/scripts/verify-relay-legacy-bootstrap.test.mjs dev/scripts/workload-identity-attribute-conditions.test.mjs",
|
||||||
"typecheck": "pnpm -r typecheck"
|
"typecheck": "pnpm -r typecheck"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
Reference in New Issue
Block a user