Files
orca/cloud/apps/relay/src/relay-server.ts
T
Jinwoo Hong b82307937a fix(relay): admit drained hosts through their own lane and stagger their return (#24446)
* fix(relay): admit drained hosts through their own lane and stagger their return

A same-cap roll's drain sends every host on the isolated cell back to the
director at once. Those hosts reconnect through the sticky lane (one slot per
director), and each one's re-placement holds that slot for most of a second
behind the region-wide inventory lock, so ordinary reconnects time out behind
them and the drained hosts retry every 2 s: ~30k 503s per drain.

The reconnect verification read now also says whether the host's home cell is
isolated for a roll right now (same predicate re-placement uses). Those hosts
release the sticky slot after the read and take a separate drain-return lane
(1 per director, matching the store's per-director placement serialization).
When that lane is full the host gets a Retry-After that reserves the next
free service slot, paced by the measured re-placement time and capped at 300 s.

Claude-Session: ced32ebb-7155-4413-adad-1eccd14c2010

* fix(relay): keep drain returns inside the placement pool budget and their own slot

Review follow-ups for the drain-return lane:
- The lane now borrows placement permits (never placement's last, never ahead
  of a queued placement), so placement + sticky still bounds the database pool.
- A host's own early retry (row-busy redial, duplicate dial) gets the 2 s lane
  interval instead of a fresh slot behind the cohort, and a host that returns
  early to the same director keeps its reserved slot.
- Classification also excludes an open migration row whose lease counter
  lapsed, matching the re-placement rule.
- The load test now runs five directors behind random routing with a shared
  inventory lock.

Claude-Session: ced32ebb-7155-4413-adad-1eccd14c2010
2026-10-01 15:34:23 -04:00

586 lines
21 KiB
TypeScript

import { createAdaptorServer } from '@hono/node-server'
import {
hasAdmissionCapacity,
HostDataAuthSchema,
parseRelayHostCapabilities,
RELAY_ADMISSION_BUDGETS,
RELAY_HOST_CAPABILITIES_HEADER,
RELAY_CLOSE_CODE,
RELAY_DEFAULT_REGION,
RELAY_PROTOCOL_LIMITS,
RelayAuthSchema
} from '@orca-cloud/relay-contract'
import type { IncomingMessage } from 'node:http'
import { randomUUID } from 'node:crypto'
import { performance } from 'node:perf_hooks'
import { WebSocketServer } from 'ws'
import type WebSocket from 'ws'
import type { RawData } from 'ws'
import { createRelayApp } from './app.js'
import { RelayAssignmentStore } from './assignment-store.js'
import type { RelayConfig } from './config.js'
import { RelayCredentialStore } from './credential-store.js'
import { readRelayDatabasePoolPressure, type RelayDatabase } from './database.js'
import { HostSessionRegistry } from './host-session-registry.js'
import { observeRelayDatabase } from './observed-relay-database.js'
import { RelayObservability } from './relay-observability.js'
import { combineRegionalRehomeSafety } from './regional-rehome-safety.js'
import { RelayConnectionLedger, type RelayConnectionUpgrade } from './relay-connection-ledger.js'
import { createRelayReadiness } from './relay-readiness.js'
import { createRelayTokenVerifier, readBearer } from './relay-token-verifier.js'
import { closeRelayWebSocket } from './relay-websocket-close.js'
import { ProcessQueuedByteBudget } from './splice-forwarder.js'
// A malformed percent-escape in the request target must be a client error, never a URIError
// thrown out of the `upgrade` listener (which is uncaught and kills the process).
function decodePathSegment(value: string): string | null {
try {
return decodeURIComponent(value)
} catch {
return null
}
}
function rejectUpgrade(socket: NodeJS.WritableStream, status: number, message: string): void {
socket.write(`HTTP/1.1 ${status} ${message}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`)
if ('destroy' in socket && typeof socket.destroy === 'function') socket.destroy()
}
function noDelay(socket: WebSocket): void {
const transport = (socket as WebSocket & { _socket?: { setNoDelay: (enabled: boolean) => void } })
._socket
transport?.setNoDelay(true)
}
// Why: a ws receiver error (oversize or malformed frame) with no 'error'
// listener throws process-wide; ws itself already closes the socket after
// emitting it, so logging is all that is left to do.
function guardSocketErrors(socket: WebSocket, kind: string): void {
socket.on('error', (error) => {
console.warn(`[orca-relay] ${kind} socket error: ${error.message}`)
})
}
function admissionSource(request: IncomingMessage): string {
const forwarded = request.headers['x-forwarded-for']
const chain = (Array.isArray(forwarded) ? forwarded.join(',') : (forwarded ?? ''))
.split(',')
.map((entry) => entry.trim())
.filter(Boolean)
// Google Front End appends client and load-balancer addresses after any
// caller-supplied values, so only the penultimate hop is trustworthy.
return chain.length >= 2 ? chain.at(-2)! : (request.socket.remoteAddress ?? 'unknown')
}
function firstPayload(raw: RawData, expectedType: string): unknown {
try {
const parsed = JSON.parse(raw.toString()) as Record<string, unknown>
if (parsed.type !== expectedType) return null
const { type: _type, ...rest } = parsed
return rest
} catch {
return null
}
}
export function createRelayServer(
config: RelayConfig,
database: RelayDatabase,
options: {
now?: () => number
random?: () => number
connectionLedgerLimits?: { hardCap: number; controlReserve: number }
cellIncarnation?: string
} = {}
) {
const cellIncarnation = options.cellIncarnation ?? randomUUID()
const observability = new RelayObservability({
role: config.role,
cellId: config.cellId,
region: config.region ?? RELAY_DEFAULT_REGION
})
const observedDatabase = observeRelayDatabase(database, observability)
const controls = new WebSocketServer({
noServer: true,
clientTracking: false,
perMessageDeflate: false,
maxPayload: 1024 * 1024
})
const verifyRelayToken = createRelayTokenVerifier(config)
const store = new RelayCredentialStore(observedDatabase, options.now)
const assignments = new RelayAssignmentStore(observedDatabase, options.now, {
requireLiveCells: config.role === 'director',
regionalRehomeCohortPercent: config.regionCorrectionCohortPercent ?? 0,
// The director runs in the database's region; only its rehome readers use this.
regionalRehomeDirectorRegion:
config.role === 'director' ? (config.region ?? RELAY_DEFAULT_REGION) : undefined,
recordControlRenewal: (durationMs, outcome) =>
observability.recordControlRenewal?.(durationMs, outcome)
})
const readiness = createRelayReadiness(observedDatabase, config.jwksUrl, {
jwksGraceMs: config.readinessJwksGraceMs,
sqlGraceMs: config.readinessSqlGraceMs,
observe: (observation) => observability.recordReadiness(observation),
observeGrace: (event) => observability.recordReadinessGrace(event)
})
const ready = readiness.check
const queuedBytes = new ProcessQueuedByteBudget()
const sessions = new HostSessionRegistry(
config,
verifyRelayToken,
store,
assignments,
queuedBytes,
observability,
options.now,
options.random,
cellIncarnation
)
const app = createRelayApp(config, {
store,
assignments,
drain: (graceMs, options) => sessions.drain(graceMs, options ?? {}),
drainHost: (input) => sessions.drainHost(input),
idleRehome: (input) => {
const now = (options.now ?? Date.now)()
if (input.directorSafety.observedAt > now || now - input.directorSafety.observedAt > 60_000) {
return Promise.resolve({ outcome: 'deferred', reason: 'director-safety-stale' })
}
return sessions.idleRehome(input,
() => assignments.commitIdleRegionalRehome(input, combineRegionalRehomeSafety(
input.directorSafety,
{ ...observability.regionalRehomeRuntimeSafety(), ...readRelayDatabasePoolPressure(database) }
), input.cohortPercent),
() => assignments.reconcileIdleRegionalRehome(input)
)
},
regionalRehomeTrustProbeHostExists: (input) => sessions.get(input) !== null,
cellIncarnation,
isDraining: () => sessions.isDraining(),
runtimeCounts: () => runtimeCounts(),
regionalRehomeSafetySnapshot: () => ({
...observability.regionalRehomeRuntimeSafety(),
...readRelayDatabasePoolPressure(database)
}),
ready,
readinessDegradation: () => readiness.degradedDependencies(),
recordAssignmentAdmission: (outcome) => observability.recordAssignmentAdmission?.(outcome),
recordAssignmentRejectionReason: (lane, reason) =>
observability.recordAssignmentRejectionReason?.(lane, reason),
recordDrainReturnRetryAfter: (seconds) => observability.recordDrainReturnRetryAfter?.(seconds),
recordRegionRequest: (region) => observability.recordRegionRequest?.(region),
recordRegionSelection: (input) => observability.recordRegionSelection?.(input)
})
const observedFetch: typeof app.fetch = async (...args) => {
const startedAt = performance.now()
try {
return await app.fetch(...args)
} finally {
observability.recordHttp(performance.now() - startedAt)
}
}
const server = createAdaptorServer({ fetch: observedFetch })
const clients = new WebSocketServer({
noServer: true,
clientTracking: false,
perMessageDeflate: false,
maxPayload: RELAY_PROTOCOL_LIMITS.maxFrameBytes
})
const dataSockets = new WebSocketServer({
noServer: true,
clientTracking: false,
perMessageDeflate: false,
maxPayload: RELAY_PROTOCOL_LIMITS.maxFrameBytes
})
let preAuthConnections = 0
let totalConnections = 0
const configuredConnectionLimits =
config.connectionHardCap === undefined
? null
: (options.connectionLedgerLimits ?? {
hardCap: config.connectionHardCap,
controlReserve: RELAY_ADMISSION_BUDGETS.reservedHostControls
})
const connectionLedger =
configuredConnectionLimits === null
? null
: new RelayConnectionLedger(
configuredConnectionLimits.hardCap,
configuredConnectionLimits.controlReserve
)
const preAuthBySource = new Map<string, number>()
const preAuthAttemptsBySource = new Map<string, { windowStartedAt: number; count: number }>()
const admit = (source: string): boolean => {
const now = Date.now()
const currentWindow = preAuthAttemptsBySource.get(source)
const attempts =
!currentWindow || now - currentWindow.windowStartedAt >= 60_000
? { windowStartedAt: now, count: 0 }
: currentWindow
const sourceCount = preAuthBySource.get(source) ?? 0
if (
!hasAdmissionCapacity({
totalRequests:
connectionLedger?.counts().physicalConnections ?? totalConnections,
preAuthConnections,
sourcePreAuthConnections: sourceCount,
totalRequestCeiling:
configuredConnectionLimits === null
? undefined
: configuredConnectionLimits.hardCap - configuredConnectionLimits.controlReserve
}) ||
attempts.count >= RELAY_ADMISSION_BUDGETS.maxPreAuthAttemptsPerSourcePerMinute
) {
return false
}
attempts.count++
preAuthAttemptsBySource.delete(source)
preAuthAttemptsBySource.set(source, attempts)
// A bounded LRU keeps source churn from becoming its own memory attack.
if (preAuthAttemptsBySource.size > 4_096) {
preAuthAttemptsBySource.delete(preAuthAttemptsBySource.keys().next().value!)
}
preAuthConnections++
preAuthBySource.set(source, sourceCount + 1)
return true
}
const authenticated = (source: string): void => {
preAuthConnections = Math.max(0, preAuthConnections - 1)
const next = (preAuthBySource.get(source) ?? 1) - 1
if (next <= 0) preAuthBySource.delete(source)
else preAuthBySource.set(source, next)
}
const trackConnection = (
socket: WebSocket,
upgrade: RelayConnectionUpgrade | null
): void => {
if (upgrade) {
upgrade.promote(socket)
return
}
totalConnections++
socket.once('close', () => {
totalConnections = Math.max(0, totalConnections - 1)
})
}
const awaitFirstFrame = (
socket: WebSocket,
source: string,
callback: (raw: RawData) => Promise<void>
): void => {
let finished = false
const timer = setTimeout(() => {
if (finished) return
finished = true
authenticated(source)
observability.recordAuth(false)
closeRelayWebSocket(socket, RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL, 'first frame timeout')
}, RELAY_PROTOCOL_LIMITS.firstFrameDeadlineMs)
socket.once('message', (raw, binary) => {
if (finished) return
finished = true
clearTimeout(timer)
authenticated(source)
if (binary) {
observability.recordAuth(false)
closeRelayWebSocket(
socket,
RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL,
'first frame must be text'
)
return
}
void callback(raw).catch((error: unknown) => {
console.warn(
'[orca-relay] first frame handler failed',
error instanceof Error ? error.message : ''
)
closeRelayWebSocket(
socket,
RELAY_CLOSE_CODE.LIMIT_EXCEEDED,
'relay temporarily unavailable'
)
})
})
socket.once('close', () => {
if (!finished) {
finished = true
clearTimeout(timer)
authenticated(source)
}
})
}
server.on('upgrade', (request, socket, head) => {
const url = new URL(request.url ?? '/', config.publicUrl)
const source = admissionSource(request)
if (url.search) {
rejectUpgrade(socket, 400, 'Bad Request')
return
}
if (url.pathname.startsWith('/v1/connect/')) {
const hostId = decodePathSegment(url.pathname.slice('/v1/connect/'.length))
if (hostId === null || !/^[A-Za-z0-9_-]{16}$/.test(hostId)) {
rejectUpgrade(socket, 429, 'Too Many Requests')
return
}
const phoneAdmission = connectionLedger?.tryReservePhone() ?? null
if (connectionLedger && !phoneAdmission) {
rejectUpgrade(socket, 503, 'Service Unavailable')
return
}
if (!admit(source)) {
phoneAdmission?.upgrade.release()
phoneAdmission?.hostData.release()
rejectUpgrade(socket, 429, 'Too Many Requests')
return
}
const releasePhoneUpgrade = (): void => {
authenticated(source)
phoneAdmission?.upgrade.release()
phoneAdmission?.hostData.release()
}
socket.once('close', releasePhoneUpgrade)
try {
clients.handleUpgrade(request, socket, head, (webSocket) => {
socket.off('close', releasePhoneUpgrade)
trackConnection(webSocket, phoneAdmission?.upgrade ?? null)
guardSocketErrors(webSocket, 'client')
if (phoneAdmission) {
webSocket.once('close', () => phoneAdmission.hostData.release())
}
noDelay(webSocket)
awaitFirstFrame(webSocket, source, async (raw) => {
const auth = RelayAuthSchema.safeParse(firstPayload(raw, 'relay-auth'))
if (!auth.success) {
phoneAdmission?.hostData.release()
observability.recordAuth(false)
webSocket.send(
JSON.stringify({ type: 'relay-hello', ok: false, code: RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL })
)
closeRelayWebSocket(
webSocket,
RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL,
'invalid relay auth'
)
return
}
if (config.role === 'director') {
const invite = await store.resolveInviteForMove(hostId, auth.data.credential)
const identity = invite ? { userId: invite.userId, relayHostId: hostId } : null
// Released combined-service invites gain their first durable cell assignment here.
const assignment = identity
? ((await assignments.resolve(identity)) ?? (await assignments.assign(identity)))
: null
if (!invite || !assignment) {
phoneAdmission?.hostData.release()
observability.recordAuth(false)
webSocket.send(
JSON.stringify({
type: 'relay-hello',
ok: false,
code: RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL
})
)
closeRelayWebSocket(
webSocket,
RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL,
'invalid invite'
)
return
}
phoneAdmission?.hostData.release()
observability.recordAuth(true)
webSocket.send(
JSON.stringify({
type: 'relay-moved',
v: 1,
cellUrl: assignment.cellUrl,
assignmentEpoch: assignment.assignmentEpoch
})
)
closeRelayWebSocket(webSocket, RELAY_CLOSE_CODE.DRAINING, 'connect to assigned cell')
return
}
await sessions.acceptClient(
webSocket,
hostId,
auth.data.credential,
phoneAdmission?.hostData
)
})
})
} catch {
socket.off('close', releasePhoneUpgrade)
releasePhoneUpgrade()
socket.destroy()
}
return
}
if (url.pathname.startsWith('/v1/host/data/')) {
if (config.role === 'director') {
rejectUpgrade(socket, 404, 'Not Found')
return
}
const connId = decodePathSegment(url.pathname.slice('/v1/host/data/'.length))
if (!connId || connId.length > 128) {
rejectUpgrade(socket, 429, 'Too Many Requests')
return
}
const dataUpgrade = connectionLedger?.tryReserveHostData(connId) ?? null
if (connectionLedger && !dataUpgrade) {
rejectUpgrade(socket, 503, 'Service Unavailable')
return
}
if (!admit(source)) {
dataUpgrade?.release()
rejectUpgrade(socket, 429, 'Too Many Requests')
return
}
const releaseDataUpgrade = (): void => {
authenticated(source)
dataUpgrade?.release()
}
socket.once('close', releaseDataUpgrade)
try {
dataSockets.handleUpgrade(request, socket, head, (webSocket) => {
socket.off('close', releaseDataUpgrade)
trackConnection(webSocket, dataUpgrade)
guardSocketErrors(webSocket, 'host-data')
noDelay(webSocket)
awaitFirstFrame(webSocket, source, async (raw) => {
const auth = HostDataAuthSchema.safeParse(firstPayload(raw, 'host-data-auth'))
if (!auth.success) {
observability.recordAuth(false)
closeRelayWebSocket(
webSocket,
RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL,
'invalid host data auth'
)
return
}
const accepted = await sessions.acceptHostData(
webSocket,
connId,
auth.data.connTicket,
auth.data.generation
)
if (accepted) dataUpgrade?.commitHostData()
})
})
} catch {
socket.off('close', releaseDataUpgrade)
releaseDataUpgrade()
socket.destroy()
}
return
}
if (url.pathname !== '/v1/host/control') {
rejectUpgrade(socket, 404, 'Not Found')
return
}
if (config.role === 'director') {
rejectUpgrade(socket, 404, 'Not Found')
return
}
const bearer = readBearer(request.headers.authorization)
if (!bearer) {
observability.recordAuth(false)
rejectUpgrade(socket, 401, 'Unauthorized')
return
}
void verifyRelayToken(bearer).then((identity) => {
if (socket.destroyed) return
if (!identity) {
observability.recordAuth(false)
rejectUpgrade(socket, 401, 'Unauthorized')
return
}
const isRebind = sessions.hasActiveControl({
userId: identity.sub,
relayHostId: identity.relayHostId
})
const controlUpgrade = connectionLedger?.tryReserveControl(isRebind) ?? null
if (
(connectionLedger && !controlUpgrade) ||
(!connectionLedger && totalConnections >= RELAY_ADMISSION_BUDGETS.cloudRunConcurrency)
) {
rejectUpgrade(
socket,
connectionLedger ? 503 : 429,
connectionLedger ? 'Service Unavailable' : 'Too Many Requests'
)
return
}
// Register the release before anything else can throw: the outer catch
// destroys the socket, so a close-registered release cannot leak the
// reserved connection unit.
const releaseControlUpgrade = (): void => controlUpgrade?.release()
socket.once('close', releaseControlUpgrade)
observability.recordAuth(true)
try {
controls.handleUpgrade(request, socket, head, (webSocket) => {
socket.off('close', releaseControlUpgrade)
trackConnection(webSocket, controlUpgrade)
guardSocketErrors(webSocket, 'control')
noDelay(webSocket)
sessions.acceptControl(
webSocket,
identity,
controlUpgrade?.inclusionWatermark,
parseRelayHostCapabilities(request.headers[RELAY_HOST_CAPABILITIES_HEADER])
)
})
} catch {
socket.off('close', releaseControlUpgrade)
releaseControlUpgrade()
socket.destroy()
}
}).catch((error: unknown) => {
// A throw in the upgrade handling above must cost this socket, not the process.
console.warn(
`[orca-relay] control upgrade failed: ${error instanceof Error ? error.message : 'unknown'}`
)
socket.destroy()
})
})
server.on('close', () => {
controls.close()
clients.close()
dataSockets.close()
})
const runtimeCounts = () => {
const ledgerCounts = connectionLedger?.counts()
return {
totalConnections: ledgerCounts?.physicalConnections ?? totalConnections,
preAuthConnections,
...sessions.runtimeCounts(),
queuedBytes: queuedBytes.current(),
...(ledgerCounts
? {
inFlightConnections: ledgerCounts.inFlightConnections,
reservedConnectionUnits: ledgerCounts.reservedConnectionUnits,
enforcedConnectionUnits: ledgerCounts.enforcedConnectionUnits
}
: {})
}
}
const connectionSnapshot = () => connectionLedger?.snapshot()
return {
server,
sessions,
store,
assignments,
queuedBytes,
observability,
runtimeCounts,
connectionSnapshot,
ready,
cellIncarnation
}
}