perf(relay): batch control lease renewals per cell instead of one write transaction per host (#21303)

* perf(relay): batch control lease renewals per cell instead of one write transaction per host

Every connected desktop renewed its own control lease with its own single-row
write transaction every 30s. At ~14,000 hosts that is ~470 write transactions
per second fleet-wide, each with its own transaction id, all updating the same
few heap pages of relay_assignments and relay_assignment_activity_leases.
Sampling three onsets at 250ms showed no lock queue and no slow statement:
60-144 backends piled into LWLock:BufferContent and Timeout/SpinDelay inside
that one statement, and Query Insights attributed 152 of 157 seconds of
lightweight-lock wait in the onset minute to it.

The heartbeat now enqueues a due renewal instead of issuing it. A cell flushes
its queue once per second, or as soon as 500 rows are waiting, through one
statement that unnests the parameter arrays and applies the same CTE row-wise.
Concurrent writers drop from the host count to the cell count, and transaction
ids with them. Measured against a 20,000-row table: 1 row 4.1ms, 12 rows
3.3ms, 100 rows 6.6ms, 500 rows 22.7ms.

Per-session semantics are unchanged. Each enqueue still resolves on a renewal
and rejects with the outcome as its message, so the completed-attempt counter,
the staleness guard, and every close path route exactly as before, and one
outcome per row is recorded against the flush latency.

Lock order is (user_id, relay_host_id), the primary key of relay_assignments,
applied in JavaScript and repeated as the statement's ORDER BY. EXPLAIN
confirms LockRows sits above that Sort, so a batch acquires its assignment rows
in one global order. Every writer in the store locks a host's assignment row
before its migration or lease rows and only ever touches one host, so a batch
can only wait on a row a single-host writer holds, never the reverse.

One statement also means one contended row could fail the whole batch, so a
failed batch degrades to the per-host statements it replaced rather than
costing every other host on the cell its renewal.

* perf(relay): batch control lease renewals per cell instead of one write transaction per host

Every connected desktop renewed its own control lease with its own single-row
write transaction every 30s. At ~14,000 hosts that is ~470 write transactions
per second fleet-wide, each with its own transaction id, all updating the same
few heap pages of relay_assignments and relay_assignment_activity_leases.
Sampling three onsets at 250ms showed no lock queue and no slow statement:
60-144 backends piled into LWLock:BufferContent and Timeout/SpinDelay inside
that one statement, and Query Insights attributed 152 of 157 seconds of
lightweight-lock wait in the onset minute to it.

The heartbeat now enqueues a due renewal instead of issuing it. A cell flushes
its queue every second, or as soon as 200 rows are waiting, through one
statement that unnests the parameter arrays and applies the same CTE row-wise.
Concurrent writers drop from the host count to the cell count, and transaction
ids with them. Per-host buffer traffic is unchanged: 30 hits for one row, 25.5
per host at 12 rows, 30.1 per host at 200, against the 28.7 the single-row
statement reports in production.

Per-session semantics are unchanged. Each enqueue still resolves on a renewal
and rejects with the outcome as its message, so the completed-attempt counter,
the staleness guard, and every close path route as before, and one outcome per
row is recorded against the flush latency.

Row locks live until the statement commits, so a batch that waited on a
contended row would hold every other row's lock for that whole wait. The
assignment pass therefore takes its locks with SKIP LOCKED and reports a
contended host as assignment_lock_unavailable, which the registry retries on the
next tick instead of closing the control. That keeps the hold to the statement's
own execution: 11.5ms for 200 rows against a 20,000-row table, and 9.4ms with a
host wedged in a per-host transaction, where a blocking FOR UPDATE spends the
pool's whole 1s lock_timeout and then fails every row in the flush.

An unlocked present_assignment probe separates a host with no assignment row
from one the skip passed over, so a skipped row can never be mistaken for a
missing assignment and close a live desktop.

With no wait on the assignment pass the lock order is only needed for the two
later passes, and it holds: every writer takes a host's assignment row before
that host's lease rows, and a host whose assignment row is held was skipped, so
the batch never reaches its lease. markMigrationTargetRegistered is the one
writer that locks a migration row first, and it takes no further locks.

* fix(relay): answer every row of a control-renewal batch from its own lease update

Review findings on the batched renewal.

Two control leases on one host in one batch made the second report
control_activity_not_found although both were renewed: the assignment UPDATE is
offered the same target row twice, applies one source row and returns one, so
the other row_index never came back. The verdict now reads renewed_lease, which
has a row per input row, and the assignment UPDATE groups per host so it also
stops taking an arbitrary one of the two expiries instead of the later one.

The queue now partitions per (userId, relayHostId) rather than per activity, so
a second control activity for one host opens the next flush instead of sharing
this one. Belt to the statement fix, not a substitute: the store API has to be
right for the rows it accepts.

renewControlActivities recorded no outcome for a one-row flush that threw, and
none at all when every row failed validation, where a mixed batch recorded its
invalid_* rows. Both now record in a finally, the way the single-row path's
finally always did, and the error-to-outcome mapping both paths share is one
function.

The four flush fields the runtime metrics event emits had no log-based metric,
so add them next to the existing controlRenewalLatencyMs* entries. Applying the
Terraform is a separate manual step.

controlRenewalLatencyMsP50/P95/Max now measure a batched row's flush duration
rather than its own statement latency. Left named as they are for history, with
a line at the emit site recording that the meaning changed here.
This commit is contained in:
Jinwoo Hong
2026-09-17 19:25:54 -04:00
committed by GitHub
parent ddbad2218b
commit 91ade4b82a
11 changed files with 1392 additions and 134 deletions
+159 -119
View File
@@ -45,6 +45,15 @@ import {
ASSIGNMENT_CONNECTION_HEADROOM_QUERY
} from './assignment-connection-headroom-query.js'
import { AssignmentIdentityQueue } from './assignment-identity-queue.js'
import {
CONTROL_RENEWAL_BATCH_SQL,
CONTROL_RENEWAL_STATEMENT_OUTCOMES,
controlRenewalBatchParams,
orderedControlRenewalRows,
readControlRenewalOutcomes,
type ControlRenewalOutcome,
type ControlRenewalRequest
} from './control-renewal-statement.js'
import {
REGIONAL_REHOME_DEFAULT_HOST_COOLDOWN_MS
} from './database.js'
@@ -101,21 +110,8 @@ type RelayAssignmentStoreOptions = {
recordControlRenewal?: (durationMs: number, outcome: ControlRenewalOutcome) => void
}
export type ControlRenewalOutcome =
| 'renewed'
| 'assignment_not_found'
| 'activity_cell_not_authoritative'
| 'control_activity_not_found'
| 'control_activity_moved'
| 'database_error'
export type { ControlRenewalOutcome, ControlRenewalRequest }
const CONTROL_RENEWAL_OUTCOMES = new Set<ControlRenewalOutcome>([
'renewed',
'assignment_not_found',
'activity_cell_not_authoritative',
'control_activity_not_found',
'control_activity_moved'
])
export type RelayAssignment = AssignmentIdentity & {
cellId: string
cellUrl: string
@@ -3478,132 +3474,149 @@ export class RelayAssignmentStore {
})
}
// Kept as the single-row contract for callers and tests: resolves on a
// renewal and throws the outcome (or the driver's own error) otherwise.
async renewControlActivity(
identity: AssignmentIdentity,
input: { activityId: string; cellId: string; expiresAt: number }
): Promise<void> {
validateActivityId(input.activityId)
const now = this.now()
const maximumExpiresAt =
now +
ASSIGNMENT_LIMITS.activityLeaseMs +
RELAY_PROTOCOL_LIMITS.controlPingIntervalMs * 2
if (
!Number.isSafeInteger(input.expiresAt) ||
input.expiresAt <= now ||
input.expiresAt > maximumExpiresAt
) {
if (!controlRenewalExpiryIsValid(input.expiresAt, now)) {
throw new Error('invalid_activity_expiry')
}
const startedAt = performance.now()
let outcome: ControlRenewalOutcome = 'database_error'
try {
outcome =
this.database.dialect === 'postgres'
? await this.renewPostgresControlActivity(identity, input, now)
: await this.renewTransactionalControlActivity(identity, input, now)
outcome = await this.renewOneControlActivity({ identity, ...input }, now)
if (outcome !== 'renewed') throw new Error(outcome)
} catch (error) {
const message = String((error as { message?: unknown }).message)
if (CONTROL_RENEWAL_OUTCOMES.has(message as ControlRenewalOutcome)) {
outcome = message as ControlRenewalOutcome
}
outcome = controlRenewalOutcomeOfError(error)
throw error
} finally {
this.recordControlRenewal?.(performance.now() - startedAt, outcome)
}
}
private async renewPostgresControlActivity(
identity: AssignmentIdentity,
input: { activityId: string; cellId: string; expiresAt: number },
// Renews every due control lease on a cell in one write transaction, returning
// one outcome per input row in input order. Never throws for a multi-row batch:
// a caller routes its own session on its own outcome.
async renewControlActivities(
rows: readonly ControlRenewalRequest[]
): Promise<ControlRenewalOutcome[]> {
const now = this.now()
const outcomes = new Array<ControlRenewalOutcome>(rows.length)
const accepted: Array<ControlRenewalRequest & { index: number }> = []
for (const [index, row] of rows.entries()) {
const rejection = controlRenewalRejection(row, now)
if (rejection) outcomes[index] = rejection
else accepted.push({ ...row, index })
}
const startedAt = performance.now()
try {
if (accepted.length === 0) return outcomes
let results: ControlRenewalOutcome[]
try {
results = await this.executeControlRenewals(accepted, now)
} catch (error) {
if (rows.length === 1) {
outcomes[accepted[0]!.index] = controlRenewalOutcomeOfError(error)
throw error
}
results = accepted.map(() => 'database_error')
}
for (const [position, row] of accepted.entries()) outcomes[row.index] = results[position]!
return outcomes
} finally {
// Every path, so a rethrown lone renewal and an all-invalid batch are
// counted the same as a batch that reached PostgreSQL.
const durationMs = performance.now() - startedAt
for (const outcome of outcomes) this.recordControlRenewal?.(durationMs, outcome)
}
}
private async executeControlRenewals(
rows: readonly ControlRenewalRequest[],
now: number
): Promise<ControlRenewalOutcome[]> {
if (rows.length === 1) return [await this.renewOneControlActivity(rows[0]!, now)]
if (this.database.dialect !== 'postgres') {
// Correctness over throughput: the SQLite writer is serialized anyway, and
// this is the dialect the unit suites run on.
return await this.renewControlActivitiesInSeries(rows, now)
}
const ordered = orderedControlRenewalRows(rows.map((row, index) => ({ ...row, index })))
const outcomes = new Array<ControlRenewalOutcome>(rows.length)
try {
const parsed = readControlRenewalOutcomes(
await this.database.query(
CONTROL_RENEWAL_BATCH_SQL,
controlRenewalBatchParams(ordered, now)
),
ordered.length
)
for (const [position, row] of ordered.entries()) outcomes[row.index] = parsed[position]!
return outcomes
} catch (error) {
// One statement means one contended assignment row can fail the whole
// batch, so a failure degrades to the per-host statements this replaced
// rather than costing every other host on the cell its renewal.
console.warn(
JSON.stringify({
event: 'orca_relay_control_renewal_batch_failed',
rows: ordered.length,
message: String((error as { message?: unknown }).message)
})
)
await Promise.all(
ordered.map(async (row) => {
try {
outcomes[row.index] = await this.renewOneControlActivity(row, now)
} catch {
outcomes[row.index] = 'database_error'
}
})
)
return outcomes
}
}
private async renewControlActivitiesInSeries(
rows: readonly ControlRenewalRequest[],
now: number
): Promise<ControlRenewalOutcome[]> {
const outcomes: ControlRenewalOutcome[] = []
for (const row of rows) {
try {
outcomes.push(await this.renewOneControlActivity(row, now))
} catch {
outcomes.push('database_error')
}
}
return outcomes
}
// Returns the outcome; a driver or pool failure reaches the caller unchanged.
private async renewOneControlActivity(
row: ControlRenewalRequest,
now: number
): Promise<ControlRenewalOutcome> {
const row = (
await this.database.query(
`WITH assignment_state AS MATERIALIZED (
SELECT cell_id, assignment_epoch
FROM relay_assignments
WHERE user_id = ? AND relay_host_id = ?
FOR UPDATE
), migration_state AS MATERIALIZED (
SELECT migration.assignment_epoch
FROM relay_assignment_migrations migration
JOIN assignment_state assignment
ON migration.target_cell_id = assignment.cell_id
AND migration.assignment_epoch = assignment.assignment_epoch
WHERE migration.user_id = ? AND migration.relay_host_id = ?
AND migration.source_cell_id = ?
AND migration.completed_at IS NULL AND migration.aborted_at IS NULL
FOR UPDATE OF migration
), authorization_state AS MATERIALIZED (
SELECT 1 AS authorized
FROM assignment_state assignment
WHERE assignment.cell_id = ? OR EXISTS (SELECT 1 FROM migration_state)
), lease_state AS MATERIALIZED (
SELECT lease.activity_kind, lease.cell_id
FROM relay_assignment_activity_leases lease
CROSS JOIN authorization_state
WHERE lease.user_id = ? AND lease.relay_host_id = ? AND lease.activity_id = ?
FOR UPDATE OF lease
), renewed_lease AS (
UPDATE relay_assignment_activity_leases lease
SET expires_at = GREATEST(lease.expires_at, ?),
updated_at = GREATEST(lease.updated_at, ?)
FROM lease_state state
WHERE lease.user_id = ? AND lease.relay_host_id = ? AND lease.activity_id = ?
AND state.activity_kind = 'control' AND state.cell_id = ?
RETURNING 1
), renewed_assignment AS (
UPDATE relay_assignments assignment
SET lease_expires_at = GREATEST(assignment.lease_expires_at, ?),
last_activity_at = GREATEST(assignment.last_activity_at, ?)
WHERE assignment.user_id = ? AND assignment.relay_host_id = ?
AND EXISTS (SELECT 1 FROM renewed_lease)
RETURNING 1
)
SELECT CASE
WHEN NOT EXISTS (SELECT 1 FROM assignment_state)
THEN 'assignment_not_found'
WHEN NOT EXISTS (SELECT 1 FROM authorization_state)
THEN 'activity_cell_not_authoritative'
WHEN NOT EXISTS (SELECT 1 FROM lease_state)
THEN 'control_activity_not_found'
WHEN EXISTS (
SELECT 1 FROM lease_state
WHERE activity_kind <> 'control' OR cell_id <> ?
) THEN 'control_activity_moved'
WHEN EXISTS (SELECT 1 FROM renewed_assignment) THEN 'renewed'
ELSE 'control_activity_not_found'
END AS outcome`,
[
identity.userId,
identity.relayHostId,
identity.userId,
identity.relayHostId,
input.cellId,
input.cellId,
identity.userId,
identity.relayHostId,
input.activityId,
input.expiresAt,
now,
identity.userId,
identity.relayHostId,
input.activityId,
input.cellId,
input.expiresAt,
now,
identity.userId,
identity.relayHostId,
input.cellId
]
)
)[0]
if (!row) throw new Error('missing_control_renewal_outcome')
const outcome = text(row, 'outcome') as ControlRenewalOutcome
if (!CONTROL_RENEWAL_OUTCOMES.has(outcome)) throw new Error('invalid_control_renewal_outcome')
return outcome
if (this.database.dialect === 'postgres') {
return readControlRenewalOutcomes(
await this.database.query(
CONTROL_RENEWAL_BATCH_SQL,
controlRenewalBatchParams([row], now)
),
1
)[0]!
}
try {
return await this.renewTransactionalControlActivity(row.identity, row, now)
} catch (error) {
const message = String((error as { message?: unknown }).message)
if (!CONTROL_RENEWAL_STATEMENT_OUTCOMES.has(message as ControlRenewalOutcome)) throw error
return message as ControlRenewalOutcome
}
}
private async renewTransactionalControlActivity(
@@ -7900,6 +7913,33 @@ function validateActivityId(activityId: string): void {
if (!activityId || activityId.length > 256) throw new Error('invalid_activity_id')
}
function controlRenewalExpiryIsValid(expiresAt: number, now: number): boolean {
const maximumExpiresAt =
now + ASSIGNMENT_LIMITS.activityLeaseMs + RELAY_PROTOCOL_LIMITS.controlPingIntervalMs * 2
return Number.isSafeInteger(expiresAt) && expiresAt > now && expiresAt <= maximumExpiresAt
}
// A renewal that threw still owes the metric an outcome: the message carries one
// when the statement decided it, and anything else is the driver failing.
function controlRenewalOutcomeOfError(error: unknown): ControlRenewalOutcome {
const message = String((error as { message?: unknown }).message)
return CONTROL_RENEWAL_STATEMENT_OUTCOMES.has(message as ControlRenewalOutcome)
? (message as ControlRenewalOutcome)
: 'database_error'
}
function controlRenewalRejection(
row: ControlRenewalRequest,
now: number
): ControlRenewalOutcome | null {
try {
validateActivityId(row.activityId)
} catch {
return 'invalid_activity_id'
}
return controlRenewalExpiryIsValid(row.expiresAt, now) ? null : 'invalid_activity_expiry'
}
function activityKind(row: SqlRow): AssignmentActivityKind {
const value = text(row, 'activity_kind')
if (!(value in ACTIVITY_REQUEST_UNITS)) throw new Error('invalid_activity_kind')
@@ -3,6 +3,7 @@ import { ASSIGNMENT_LIMITS, RELAY_CLOSE_CODE } from '@orca-cloud/relay-contract'
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import type WebSocket from 'ws'
import { RelayAssignmentStore } from './assignment-store.js'
import { CONTROL_RENEWAL_BATCH_INTERVAL_MS } from './control-renewal-batch.js'
import type { RelayConfig } from './config.js'
import type { RelayCredentialStore } from './credential-store.js'
import { openRelayDatabase, type RelayDatabase } from './database.js'
@@ -133,6 +134,10 @@ describePostgres('expired control lease after a database outage', () => {
internals.heartbeat(session)
}
// A due renewal leaves the heartbeat as a batch enqueue, so a poll has to
// outlast the batch window before it can call the renewal missing.
const renewalPoll = { timeout: CONTROL_RENEWAL_BATCH_INTERVAL_MS + 4_000 }
const leaseRows = async (relayHostId: string) =>
await database.query(
`SELECT activity_id, cell_id FROM relay_assignment_activity_leases
@@ -149,7 +154,8 @@ describePostgres('expired control lease after a database outage', () => {
.poll(
async () =>
socket.close.mock.calls.length > 0 ||
(await leaseRows(relayHostId)).length === expectedRows
(await leaseRows(relayHostId)).length === expectedRows,
renewalPoll
)
.toBe(true)
}
@@ -212,7 +218,7 @@ describePostgres('expired control lease after a database outage', () => {
).rejects.toThrow('control_activity_moved')
heartbeat(registry, session)
await expect.poll(() => socket.close.mock.calls.length).toBe(1)
await expect.poll(() => socket.close.mock.calls.length, renewalPoll).toBe(1)
expect(socket.close).toHaveBeenCalledWith(RELAY_CLOSE_CODE.DRAINING, 'control activity moved')
expect(await leaseRows(identity.relayHostId)).toEqual([
@@ -0,0 +1,291 @@
import { ASSIGNMENT_LIMITS } from '@orca-cloud/relay-contract'
import { describe, expect, it, vi } from 'vitest'
import { RelayAssignmentStore } from './assignment-store.js'
import { CONTROL_RENEWAL_BATCH_SQL } from './control-renewal-statement.js'
import type { ControlRenewalOutcome } from './control-renewal-statement.js'
import {
openInMemoryRelayDatabase,
type RelayDatabase,
type SqlRow
} from './database.js'
const now = 1_900_000_000_000
const expiresAt = now + 105_000
function renewal(userId: string, relayHostId: string, expiry = expiresAt) {
return {
identity: { userId, relayHostId },
activityId: 'control:cell-a:1',
cellId: 'cell-a',
expiresAt: expiry
}
}
// A PostgreSQL-dialect database that answers the renewal statement without a
// server, so the statement count and its parameter arrays are observable.
class RenewalStatementProbe implements RelayDatabase {
readonly dialect = 'postgres' as const
readonly statements: Array<{ sql: string; params: unknown[] }> = []
failuresRemaining = 0
constructor(private readonly outcomeFor: (userId: string) => ControlRenewalOutcome) {}
async query(sql: string, params: unknown[] = []): Promise<SqlRow[]> {
this.statements.push({ sql, params })
if (this.failuresRemaining > 0) {
this.failuresRemaining -= 1
throw new Error('canceling statement due to statement timeout')
}
const userIds = params[0] as string[]
return userIds.map((userId, index) => ({
row_index: String(index + 1),
outcome: this.outcomeFor(userId)
}))
}
async queryLocked(): Promise<SqlRow[]> {
throw new Error('unexpected_locked_query')
}
async transaction<T>(): Promise<T> {
// Renewals must never open one: that is the write transaction per host this
// batch exists to remove.
throw new Error('unexpected_transaction')
}
async close(): Promise<void> {}
}
describe('batched control renewals on PostgreSQL', () => {
it('spends one statement on every host that came due', async () => {
const probe = new RenewalStatementProbe(() => 'renewed')
const store = new RelayAssignmentStore(probe, () => now)
const outcomes = await store.renewControlActivities([
renewal('user-a', 'host000000000001'),
renewal('user-a', 'host000000000002'),
renewal('user-b', 'host000000000003')
])
expect(outcomes).toEqual(['renewed', 'renewed', 'renewed'])
expect(probe.statements).toHaveLength(1)
expect(probe.statements[0]!.sql).toBe(CONTROL_RENEWAL_BATCH_SQL)
expect(probe.statements[0]!.params[0]).toEqual(['user-a', 'user-a', 'user-b'])
expect(probe.statements[0]!.params[4]).toEqual([expiresAt, expiresAt, expiresAt])
})
it('locks assignment rows in primary-key order and still answers in input order', async () => {
const probe = new RenewalStatementProbe((userId) =>
userId === 'user-b' ? 'control_activity_moved' : 'renewed'
)
const store = new RelayAssignmentStore(probe, () => now)
const outcomes = await store.renewControlActivities([
renewal('user-c', 'host000000000003'),
renewal('user-a', 'host000000000002'),
renewal('user-b', 'host000000000001'),
renewal('user-a', 'host000000000001')
])
// (user_id, relay_host_id) is the primary key of relay_assignments, and the
// statement's ORDER BY repeats it: no batch can queue against another in a
// different sequence.
expect(probe.statements[0]!.params[0]).toEqual(['user-a', 'user-a', 'user-b', 'user-c'])
expect(probe.statements[0]!.params[1]).toEqual([
'host000000000001',
'host000000000002',
'host000000000001',
'host000000000003'
])
expect(outcomes).toEqual([
'renewed',
'renewed',
'control_activity_moved',
'renewed'
])
})
it('keeps a malformed request out of the statement and fails only that row', async () => {
const probe = new RenewalStatementProbe(() => 'renewed')
const store = new RelayAssignmentStore(probe, () => now)
const outcomes = await store.renewControlActivities([
renewal('user-a', 'host000000000001'),
renewal('user-a', 'host000000000002', now + ASSIGNMENT_LIMITS.activityLeaseMs * 10),
{ ...renewal('user-a', 'host000000000003'), activityId: '' },
renewal('user-a', 'host000000000004')
])
expect(outcomes).toEqual([
'renewed',
'invalid_activity_expiry',
'invalid_activity_id',
'renewed'
])
expect(probe.statements[0]!.params[1]).toEqual(['host000000000001', 'host000000000004'])
})
it('degrades to one statement per host when the batch statement fails', async () => {
const probe = new RenewalStatementProbe(() => 'renewed')
probe.failuresRemaining = 1
const store = new RelayAssignmentStore(probe, () => now)
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
try {
const outcomes = await store.renewControlActivities([
renewal('user-a', 'host000000000001'),
renewal('user-a', 'host000000000002')
])
expect(outcomes).toEqual(['renewed', 'renewed'])
expect(probe.statements).toHaveLength(3)
expect(probe.statements[1]!.params[1]).toEqual(['host000000000001'])
expect(probe.statements[2]!.params[1]).toEqual(['host000000000002'])
expect(JSON.parse(String(warn.mock.calls[0]![0]))).toMatchObject({
event: 'orca_relay_control_renewal_batch_failed',
rows: 2
})
} finally {
warn.mockRestore()
}
})
it('reports a host that fails its own fallback statement without touching the rest', async () => {
const probe = new RenewalStatementProbe(() => 'renewed')
probe.failuresRemaining = 2
const store = new RelayAssignmentStore(probe, () => now)
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
try {
const outcomes = await store.renewControlActivities([
renewal('user-a', 'host000000000001'),
renewal('user-a', 'host000000000002')
])
expect(outcomes.filter((outcome) => outcome === 'renewed')).toHaveLength(1)
expect(outcomes.filter((outcome) => outcome === 'database_error')).toHaveLength(1)
} finally {
warn.mockRestore()
}
})
it('reports a contended assignment row apart from a missing one', async () => {
const probe = new RenewalStatementProbe((userId) =>
userId === 'user-b' ? 'assignment_lock_unavailable' : 'renewed'
)
const store = new RelayAssignmentStore(probe, () => now)
const outcomes = await store.renewControlActivities([
renewal('user-a', 'host000000000001'),
renewal('user-b', 'host000000000002')
])
// Retryable: SKIP LOCKED passed over the row rather than queueing the whole
// flush behind whoever held it.
expect(outcomes).toEqual(['renewed', 'assignment_lock_unavailable'])
})
it('counts a lone renewal that threw before rethrowing it', async () => {
const probe = new RenewalStatementProbe(() => 'renewed')
probe.failuresRemaining = 1
const recordControlRenewal = vi.fn()
const store = new RelayAssignmentStore(probe, () => now, { recordControlRenewal })
// A one-row flush keeps the pre-batch contract and rethrows, but the metric
// still owes an outcome for the attempt.
await expect(
store.renewControlActivities([renewal('user-a', 'host000000000001')])
).rejects.toThrow('statement timeout')
expect(recordControlRenewal).toHaveBeenCalledTimes(1)
expect(recordControlRenewal.mock.calls[0]![1]).toBe('database_error')
})
it('counts a batch in which every row was rejected before the statement', async () => {
const probe = new RenewalStatementProbe(() => 'renewed')
const recordControlRenewal = vi.fn()
const store = new RelayAssignmentStore(probe, () => now, { recordControlRenewal })
const outcomes = await store.renewControlActivities([
{ ...renewal('user-a', 'host000000000001'), activityId: '' },
renewal('user-a', 'host000000000002', now - 1)
])
expect(outcomes).toEqual(['invalid_activity_id', 'invalid_activity_expiry'])
expect(probe.statements).toHaveLength(0)
expect(recordControlRenewal.mock.calls.map((call) => call[1])).toEqual([
'invalid_activity_id',
'invalid_activity_expiry'
])
})
it('counts one renewal metric per row against the flush latency', async () => {
const probe = new RenewalStatementProbe((userId) =>
userId === 'user-b' ? 'assignment_not_found' : 'renewed'
)
const recordControlRenewal = vi.fn()
const store = new RelayAssignmentStore(probe, () => now, { recordControlRenewal })
await store.renewControlActivities([
renewal('user-a', 'host000000000001'),
renewal('user-b', 'host000000000002')
])
expect(recordControlRenewal).toHaveBeenCalledTimes(2)
expect(recordControlRenewal.mock.calls.map((call) => call[1])).toEqual([
'renewed',
'assignment_not_found'
])
})
})
describe('batched control renewals on SQLite', () => {
it('renews every host through the transactional path', async () => {
let clock = now
const database = await openInMemoryRelayDatabase()
try {
const store = new RelayAssignmentStore(database, () => clock)
await store.reconcileCells([
{ id: 'cell-a', url: 'https://relay-a.example.com', capacityRequests: 10 }
])
const hosts = ['host000000000001', 'host000000000002']
const requests = []
for (const relayHostId of hosts) {
const identity = { userId: 'user-a', relayHostId }
const assignment = await store.assign(identity)
await store.activateControl(identity, {
cellId: assignment.cellId,
assignmentEpoch: assignment.assignmentEpoch,
generation: 1
})
requests.push({
identity,
activityId: `control:${assignment.cellId}:1`,
cellId: assignment.cellId,
expiresAt: clock + 105_000
})
}
// A host with no assignment at all must not cost the others their renewal.
requests.push({
identity: { userId: 'user-a', relayHostId: 'host000000000009' },
activityId: 'control:cell-a:1',
cellId: 'cell-a',
expiresAt: clock + 105_000
})
clock += 1_000
const outcomes = await store.renewControlActivities(requests)
expect(outcomes).toEqual(['renewed', 'renewed', 'assignment_not_found'])
const leases = await database.query(
`SELECT relay_host_id, expires_at FROM relay_assignment_activity_leases
WHERE user_id = ? ORDER BY relay_host_id ASC`,
['user-a']
)
expect(leases.map((lease) => Number(lease.expires_at))).toEqual([
now + 105_000,
now + 105_000
])
} finally {
await database.close()
}
})
})
@@ -0,0 +1,231 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
CONTROL_RENEWAL_BATCH_INTERVAL_MS,
CONTROL_RENEWAL_BATCH_MAX_ROWS,
ControlRenewalBatch,
type ControlRenewalFlush
} from './control-renewal-batch.js'
import type {
ControlRenewalOutcome,
ControlRenewalRequest
} from './control-renewal-statement.js'
// Settles into the outcome the caller saw, attached at enqueue so a rejection is
// never momentarily unhandled.
function outcomeOf(renewal: Promise<void>): Promise<string> {
return renewal.then(
() => 'renewed',
(error: unknown) => String((error as { message?: unknown }).message)
)
}
function request(
host: string,
expiresAt = 1_000,
activityId = 'control:cell-a:1'
): ControlRenewalRequest {
return {
identity: { userId: 'user-a', relayHostId: host },
activityId,
cellId: 'cell-a',
expiresAt
}
}
describe('control renewal batch', () => {
beforeEach(() => vi.useFakeTimers())
afterEach(() => {
vi.clearAllTimers()
vi.useRealTimers()
})
it('spends one call on every renewal that came due in the window', async () => {
const renew = vi.fn(async (rows: readonly ControlRenewalRequest[]) =>
rows.map((): ControlRenewalOutcome => 'renewed')
)
const batch = new ControlRenewalBatch(renew)
const settled = [
batch.enqueue(request('host0000000000a1')),
batch.enqueue(request('host0000000000a2')),
batch.enqueue(request('host0000000000a3'))
]
expect(renew).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(CONTROL_RENEWAL_BATCH_INTERVAL_MS)
await expect(Promise.all(settled)).resolves.toEqual([undefined, undefined, undefined])
expect(renew).toHaveBeenCalledOnce()
expect(renew.mock.calls[0]![0].map((row) => row.identity.relayHostId)).toEqual([
'host0000000000a1',
'host0000000000a2',
'host0000000000a3'
])
})
it('routes each outcome back to the caller that asked for it', async () => {
const outcomes: ControlRenewalOutcome[] = [
'renewed',
'assignment_not_found',
'control_activity_moved'
]
const batch = new ControlRenewalBatch(async () => outcomes)
const first = outcomeOf(batch.enqueue(request('host0000000000b1')))
const second = outcomeOf(batch.enqueue(request('host0000000000b2')))
const third = outcomeOf(batch.enqueue(request('host0000000000b3')))
await vi.advanceTimersByTimeAsync(CONTROL_RENEWAL_BATCH_INTERVAL_MS)
await expect(Promise.all([first, second, third])).resolves.toEqual([
'renewed',
'assignment_not_found',
'control_activity_moved'
])
})
it('flushes on reaching the row ceiling instead of waiting out the window', async () => {
const renew = vi.fn(async (rows: readonly ControlRenewalRequest[]) =>
rows.map((): ControlRenewalOutcome => 'renewed')
)
const batch = new ControlRenewalBatch(renew)
for (let row = 0; row < CONTROL_RENEWAL_BATCH_MAX_ROWS - 1; row++) {
void batch.enqueue(request(`host${String(row).padStart(12, '0')}`))
}
expect(renew).not.toHaveBeenCalled()
void batch.enqueue(request('host0000000000zz'))
await vi.advanceTimersByTimeAsync(0)
expect(renew).toHaveBeenCalledOnce()
expect(renew.mock.calls[0]![0]).toHaveLength(CONTROL_RENEWAL_BATCH_MAX_ROWS)
// The window timer must not fire a second, empty statement.
await vi.advanceTimersByTimeAsync(CONTROL_RENEWAL_BATCH_INTERVAL_MS)
expect(renew).toHaveBeenCalledOnce()
})
it('does not hold a new window behind a statement still in PostgreSQL', async () => {
let release!: (outcomes: ControlRenewalOutcome[]) => void
const renew = vi
.fn<(rows: readonly ControlRenewalRequest[]) => Promise<ControlRenewalOutcome[]>>()
.mockImplementationOnce(
async () => await new Promise<ControlRenewalOutcome[]>((resolve) => (release = resolve))
)
.mockResolvedValue(['renewed'])
const batch = new ControlRenewalBatch(renew)
const stalled = batch.enqueue(request('host0000000000c1'))
await vi.advanceTimersByTimeAsync(CONTROL_RENEWAL_BATCH_INTERVAL_MS)
const next = batch.enqueue(request('host0000000000c2'))
await vi.advanceTimersByTimeAsync(CONTROL_RENEWAL_BATCH_INTERVAL_MS)
expect(renew).toHaveBeenCalledTimes(2)
await expect(next).resolves.toBeUndefined()
release(['renewed'])
await expect(stalled).resolves.toBeUndefined()
})
it('reports the driver failure to every caller in the flush', async () => {
const batch = new ControlRenewalBatch(async () => {
throw new Error('pool timeout')
})
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
try {
const first = outcomeOf(batch.enqueue(request('host0000000000d1')))
const second = outcomeOf(batch.enqueue(request('host0000000000d2')))
await vi.advanceTimersByTimeAsync(CONTROL_RENEWAL_BATCH_INTERVAL_MS)
await expect(Promise.all([first, second])).resolves.toEqual([
'pool timeout',
'pool timeout'
])
} finally {
warn.mockRestore()
}
})
it('supersedes a second attempt for one lease and answers both callers', async () => {
const renew = vi.fn(async (rows: readonly ControlRenewalRequest[]) =>
rows.map((): ControlRenewalOutcome => 'renewed')
)
const batch = new ControlRenewalBatch(renew)
const earlier = batch.enqueue(request('host0000000000e1', 1_000))
const later = batch.enqueue(request('host0000000000e1', 2_000))
await vi.advanceTimersByTimeAsync(CONTROL_RENEWAL_BATCH_INTERVAL_MS)
expect(renew.mock.calls[0]![0]).toEqual([
expect.objectContaining({ expiresAt: 2_000 })
])
await expect(earlier).resolves.toBeUndefined()
await expect(later).resolves.toBeUndefined()
})
it('holds a second activity for one host back to the next flush', async () => {
const renew = vi.fn(async (rows: readonly ControlRenewalRequest[]) =>
rows.map((): ControlRenewalOutcome => 'renewed')
)
const batch = new ControlRenewalBatch(renew)
const first = batch.enqueue(request('host0000000000h1', 1_000, 'control:cell-a:1'))
const second = batch.enqueue(request('host0000000000h1', 1_000, 'control:cell-a:2'))
const other = batch.enqueue(request('host0000000000h2'))
await vi.advanceTimersByTimeAsync(CONTROL_RENEWAL_BATCH_INTERVAL_MS)
// One statement updates a host's assignment row once, so the host appears in
// one flush only; the newer generation leads the next one.
expect(renew.mock.calls[0]![0].map((row) => row.activityId)).toEqual([
'control:cell-a:1',
'control:cell-a:1'
])
await expect(Promise.all([first, other])).resolves.toEqual([undefined, undefined])
await vi.advanceTimersByTimeAsync(CONTROL_RENEWAL_BATCH_INTERVAL_MS)
expect(renew).toHaveBeenCalledTimes(2)
expect(renew.mock.calls[1]![0].map((row) => row.activityId)).toEqual(['control:cell-a:2'])
await expect(second).resolves.toBeUndefined()
})
it('stays quiet for a fast flush that renewed everything', async () => {
const flushes: ControlRenewalFlush[] = []
const batch = new ControlRenewalBatch(
async () => ['renewed'],
() => ({ cellId: 'cell-a' }),
(flush) => flushes.push(flush)
)
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
try {
void batch.enqueue(request('host0000000000f1'))
await vi.advanceTimersByTimeAsync(CONTROL_RENEWAL_BATCH_INTERVAL_MS)
expect(warn).not.toHaveBeenCalled()
expect(flushes).toEqual([
{ rows: 1, durationMs: expect.any(Number), outcomes: { renewed: 1 } }
])
} finally {
warn.mockRestore()
}
})
it('logs one line with the outcome counts when a flush did not renew everything', async () => {
const batch = new ControlRenewalBatch(
async () => ['renewed', 'control_activity_not_found'],
() => ({ cellId: 'cell-a' })
)
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
try {
void outcomeOf(batch.enqueue(request('host0000000000g1')))
const missing = outcomeOf(batch.enqueue(request('host0000000000g2')))
await vi.advanceTimersByTimeAsync(CONTROL_RENEWAL_BATCH_INTERVAL_MS)
await expect(missing).resolves.toBe('control_activity_not_found')
expect(warn).toHaveBeenCalledOnce()
expect(JSON.parse(String(warn.mock.calls[0]![0]))).toMatchObject({
event: 'orca_relay_control_renewal_flush',
cellId: 'cell-a',
rows: 2,
outcomes: { renewed: 1, control_activity_not_found: 1 }
})
} finally {
warn.mockRestore()
}
})
})
@@ -0,0 +1,148 @@
import { performance } from 'node:perf_hooks'
import type {
ControlRenewalOutcome,
ControlRenewalRequest
} from './control-renewal-statement.js'
// One flush per second turns the fleet's control-lease write rate into a
// function of the cell count rather than the host count: a cell's ~10 due
// renewals per second become one write transaction instead of ten. Well inside
// the 105s lease runway, so a host that misses a window is never at risk.
export const CONTROL_RENEWAL_BATCH_INTERVAL_MS = 1_000
// Ceiling on the parameter arrays. Row locks live until the statement commits,
// so this is what bounds how long one flush holds them: measured at 11.5ms for
// 200 rows against a 20,000-row table, and 9.4ms with a host wedged in a
// per-host transaction.
export const CONTROL_RENEWAL_BATCH_MAX_ROWS = 200
// A flush slower than this is the only latency worth a line; the metrics event
// carries the distribution.
const CONTROL_RENEWAL_SLOW_FLUSH_MS = 250
export type ControlRenewalFlush = {
rows: number
durationMs: number
outcomes: Record<string, number>
}
type PendingWaiter = { resolve: () => void; reject: (error: unknown) => void }
type PendingRenewal = { request: ControlRenewalRequest; waiters: PendingWaiter[] }
type QueuedRenewal = { request: ControlRenewalRequest; waiter: PendingWaiter }
// Per host, not per activity: one statement updates a host's assignment row
// once, so two activities for the same host must not share a flush.
function pendingKey(request: ControlRenewalRequest): string {
return [request.identity.userId, request.identity.relayHostId].join('\u0000')
}
// Collects the control-lease renewals a cell owes and spends one statement on
// them. Each caller still gets the single-renewal contract: the promise resolves
// on `renewed` and rejects with the outcome as its message otherwise, so callers
// keep their per-session error routing unchanged.
export class ControlRenewalBatch {
private pending = new Map<string, PendingRenewal>()
// Renewals a host cannot contribute to the flush being built; they open the
// next one.
private deferred: QueuedRenewal[] = []
private timer: ReturnType<typeof setTimeout> | null = null
constructor(
private readonly renew: (
rows: readonly ControlRenewalRequest[]
) => Promise<ControlRenewalOutcome[]>,
private readonly logFields: () => Record<string, unknown> = () => ({}),
private readonly observe?: (flush: ControlRenewalFlush) => void
) {}
enqueue(request: ControlRenewalRequest): Promise<void> {
return new Promise<void>((resolve, reject) => {
this.admit({ request, waiter: { resolve, reject } })
})
}
private admit(queued: QueuedRenewal): void {
const key = pendingKey(queued.request)
const existing = this.pending.get(key)
if (existing && existing.request.activityId !== queued.request.activityId) {
this.deferred.push(queued)
this.scheduleFlush()
return
}
if (existing) {
// A second attempt at the same lease inside one window supersedes the
// first expiry; both callers still hear the outcome they waited for.
existing.request = {
...queued.request,
expiresAt: Math.max(existing.request.expiresAt, queued.request.expiresAt)
}
existing.waiters.push(queued.waiter)
return
}
this.pending.set(key, { request: queued.request, waiters: [queued.waiter] })
if (this.pending.size >= CONTROL_RENEWAL_BATCH_MAX_ROWS) {
void this.flush()
return
}
this.scheduleFlush()
}
private scheduleFlush(): void {
this.timer ??= setTimeout(() => {
this.timer = null
void this.flush()
}, CONTROL_RENEWAL_BATCH_INTERVAL_MS)
this.timer.unref?.()
}
// Flushes run concurrently on purpose: a statement stalled in PostgreSQL must
// not hold back the renewals that came due while it was waiting.
async flush(): Promise<void> {
if (this.timer) {
clearTimeout(this.timer)
this.timer = null
}
const batch = [...this.pending.values()]
this.pending = new Map()
// Re-admitted against the empty map, so a host deferred out of this flush
// leads the next one.
const deferred = this.deferred
this.deferred = []
for (const queued of deferred) this.admit(queued)
if (batch.length === 0) return
const startedAt = performance.now()
let outcomes: ControlRenewalOutcome[]
try {
outcomes = await this.renew(batch.map((entry) => entry.request))
} catch (error) {
for (const entry of batch) for (const waiter of entry.waiters) waiter.reject(error)
this.report(batch.length, performance.now() - startedAt, { flush_failed: batch.length })
return
}
const counts: Record<string, number> = {}
for (const [index, entry] of batch.entries()) {
const outcome = outcomes[index] ?? 'database_error'
counts[outcome] = (counts[outcome] ?? 0) + 1
for (const waiter of entry.waiters) {
if (outcome === 'renewed') waiter.resolve()
else waiter.reject(new Error(outcome))
}
}
this.report(batch.length, performance.now() - startedAt, counts)
}
private report(rows: number, durationMs: number, outcomes: Record<string, number>): void {
this.observe?.({ rows, durationMs, outcomes })
const renewed = outcomes.renewed ?? 0
if (durationMs <= CONTROL_RENEWAL_SLOW_FLUSH_MS && renewed === rows) return
console.warn(
JSON.stringify({
event: 'orca_relay_control_renewal_flush',
...this.logFields(),
rows,
durationMs: Math.round(durationMs),
outcomes
})
)
}
}
@@ -1,8 +1,11 @@
import { performance } from 'node:perf_hooks'
import { ASSIGNMENT_LIMITS, RELAY_PROTOCOL_LIMITS } from '@orca-cloud/relay-contract'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { RelayAssignmentStore } from './assignment-store.js'
import { CONTROL_RENEWAL_BATCH_SQL } from './control-renewal-statement.js'
import {
openRelayDatabase,
POSTGRES_LOCK_TIMEOUT_MS,
type RelayDatabase,
type RelayLockOptions,
type SqlRow
@@ -22,7 +25,9 @@ const targetCell = {
capacityRequests: 100
}
const userId = 'control-renewal-postgres-user'
const identities = Array.from({ length: 6 }, (_, index) => ({
// Indexes 0-5 belong to the single-renewal cases below, which mutate their
// host's migration and lease state; the batch cases own 6-13.
const identities = Array.from({ length: 14 }, (_, index) => ({
userId,
relayHostId: `controlrenewal${index + 1}`
}))
@@ -72,7 +77,7 @@ class StallFirstRenewalQueryDatabase implements RelayDatabase {
constructor(private readonly database: RelayDatabase) {}
async query(sql: string, params?: unknown[]): Promise<SqlRow[]> {
if (this.stallNext && sql.includes('WITH assignment_state AS MATERIALIZED')) {
if (this.stallNext && sql === CONTROL_RENEWAL_BATCH_SQL) {
this.stallNext = false
this.stalled.resolve()
await this.continue.promise
@@ -103,7 +108,7 @@ class RenewalQueryProbeDatabase implements RelayDatabase {
constructor(private readonly database: RelayDatabase) {}
async query(sql: string, params?: unknown[]): Promise<SqlRow[]> {
if (sql.includes('WITH assignment_state AS MATERIALIZED')) this.renewalQueries++
if (sql === CONTROL_RENEWAL_BATCH_SQL) this.renewalQueries++
return await this.database.query(sql, params)
}
@@ -332,6 +337,164 @@ describePostgres('PostgreSQL control renewal', () => {
).rejects.toThrow('invalid_activity_expiry')
})
it('renews every due host in one autocommitted statement', async () => {
const probe = new RenewalQueryProbeDatabase(database)
const store = new RelayAssignmentStore(probe, () => now)
const batch = identities.slice(6, 10)
now += 30_000
const expiresAt = now + 105_000
const outcomes = await store.renewControlActivities(
batch.map((identity) => ({
identity,
activityId: controlId(sourceCell.id),
cellId: sourceCell.id,
expiresAt
}))
)
expect(outcomes).toEqual(['renewed', 'renewed', 'renewed', 'renewed'])
expect(probe.renewalQueries).toBe(1)
expect(probe.transactions).toBe(0)
const leases = await database.query(
`SELECT relay_host_id, expires_at FROM relay_assignment_activity_leases
WHERE user_id = ? AND activity_id = ? ORDER BY relay_host_id ASC`,
[userId, controlId(sourceCell.id)]
)
expect(
leases
.filter((lease) =>
batch.some((identity) => identity.relayHostId === lease.relay_host_id)
)
.map((lease) => Number(lease.expires_at))
).toEqual([expiresAt, expiresAt, expiresAt, expiresAt])
})
it('reports each host its own verdict inside one batch', async () => {
const store = new RelayAssignmentStore(database, () => now)
now += 30_000
const expiresAt = now + 105_000
const live = identities[10]!
const absent = { userId, relayHostId: 'controlrenewalgone' }
const outcomes = await store.renewControlActivities([
{ identity: absent, activityId: controlId(sourceCell.id), cellId: sourceCell.id, expiresAt },
{ identity: live, activityId: controlId(sourceCell.id), cellId: sourceCell.id, expiresAt },
{
identity: live,
activityId: controlId(targetCell.id),
cellId: targetCell.id,
expiresAt
}
])
expect(outcomes).toEqual([
'assignment_not_found',
'renewed',
'activity_cell_not_authoritative'
])
})
it('passes over a host whose assignment row is held and renews the rest', async () => {
const store = new RelayAssignmentStore(database, () => now)
now += 30_000
const expiresAt = now + 105_000
const held = identities[11]!
const free = identities[12]!
const locked = signal()
const release = signal()
// Holds the row the way every per-host transactional path does.
const holder = database.transaction(async (transaction) => {
await transaction.queryLocked(
`SELECT * FROM relay_assignments WHERE user_id = ? AND relay_host_id = ?`,
[held.userId, held.relayHostId]
)
locked.resolve()
await release.promise
})
await locked.promise
const startedAt = performance.now()
const outcomes = await store.renewControlActivities(
[held, free].map((identity) => ({
identity,
activityId: controlId(sourceCell.id),
cellId: sourceCell.id,
expiresAt
}))
)
const elapsedMs = performance.now() - startedAt
release.resolve()
await holder
expect(outcomes).toEqual(['assignment_lock_unavailable', 'renewed'])
// It skipped rather than queued: a blocking FOR UPDATE would have spent the
// pool's whole lock_timeout here and failed the free host too.
expect(elapsedMs).toBeLessThan(POSTGRES_LOCK_TIMEOUT_MS)
const lease = (
await database.query(
`SELECT expires_at FROM relay_assignment_activity_leases
WHERE user_id = ? AND relay_host_id = ? AND activity_id = ?`,
[free.userId, free.relayHostId, controlId(sourceCell.id)]
)
)[0]
expect(Number(lease!.expires_at)).toBe(expiresAt)
})
it('renews both of one host\u2019s control leases in a single batch', async () => {
const store = new RelayAssignmentStore(database, () => now)
const identity = identities[13]!
// Two live control leases on one host. Written directly because
// activateControl retires the prior generation, and what is under test is the
// statement's row-wise behaviour, not how the second lease came to exist.
await database.query(
`INSERT INTO relay_assignment_activity_leases
(user_id, relay_host_id, activity_id, activity_kind, cell_id,
request_units, expires_at, updated_at)
VALUES (?, ?, ?, 'control', ?, 1, ?, ?)`,
[
identity.userId,
identity.relayHostId,
`control:${sourceCell.id}:2`,
sourceCell.id,
now,
now
]
)
now += 30_000
const expiresAt = now + 105_000
const outcomes = await store.renewControlActivities(
[1, 2].map((generation) => ({
identity,
activityId: `control:${sourceCell.id}:${generation}`,
cellId: sourceCell.id,
expiresAt: expiresAt - generation
}))
)
expect(outcomes).toEqual(['renewed', 'renewed'])
const leases = await database.query(
`SELECT activity_id, expires_at FROM relay_assignment_activity_leases
WHERE user_id = ? AND relay_host_id = ? ORDER BY activity_id ASC`,
[identity.userId, identity.relayHostId]
)
expect(leases.map((lease) => Number(lease.expires_at))).toEqual([
expiresAt - 1,
expiresAt - 2
])
// The assignment row is written once, carrying the later of the two.
const row = (
await database.query(
`SELECT lease_expires_at, last_activity_at FROM relay_assignments
WHERE user_id = ? AND relay_host_id = ?`,
[identity.userId, identity.relayHostId]
)
)[0]
expect(Number(row!.lease_expires_at)).toBe(expiresAt - 1)
expect(Number(row!.last_activity_at)).toBe(now)
})
it('uses one autocommitted PostgreSQL statement for a steady renewal', async () => {
const probe = new RenewalQueryProbeDatabase(database)
const store = new RelayAssignmentStore(probe, () => now)
@@ -0,0 +1,230 @@
import type { AssignmentIdentity } from './assignment-identity-queue.js'
import type { SqlRow } from './database.js'
export type ControlRenewalOutcome =
| 'renewed'
| 'assignment_not_found'
| 'activity_cell_not_authoritative'
| 'control_activity_not_found'
| 'control_activity_moved'
// The host's assignment row was already locked by one of the per-host
// transactional paths. Retryable, and never a reason to close a control: the
// next tick is 15s away and the lease has 105s on it.
| 'assignment_lock_unavailable'
// Decided per row before the statement runs, so one malformed request cannot
// cost the rest of the batch its renewal.
| 'invalid_activity_id'
| 'invalid_activity_expiry'
| 'database_error'
// Outcomes the statement itself can report. `database_error` is raised by the
// driver, and `invalid_activity_expiry` is decided per row before the statement
// is built, so neither can come back as a row.
export const CONTROL_RENEWAL_STATEMENT_OUTCOMES = new Set<ControlRenewalOutcome>([
'renewed',
'assignment_not_found',
'activity_cell_not_authoritative',
'control_activity_not_found',
'control_activity_moved',
'assignment_lock_unavailable'
])
export type ControlRenewalRequest = {
identity: AssignmentIdentity
activityId: string
cellId: string
expiresAt: number
}
// LOCK ORDER - (user_id, relay_host_id), the primary key of relay_assignments,
// applied here and repeated as the statement's ORDER BY so it holds whether the
// planner walks the primary-key index or sorts under the LockRows node.
//
// The batch never waits for an assignment row: SKIP LOCKED reports a contended
// host separately instead. That is what bounds how long a flush holds its locks
// to its own execution time, because row locks live until the statement commits,
// and it is why one host wedged in a per-host transaction cannot stall the
// renewals of every other host sharing the flush.
//
// With no wait on the assignment pass, the deadlock question reduces to the two
// later passes. Every writer in this store locks a host's assignment row before
// that host's lease rows (`assignmentRow` then `lockAssignmentActivities`), and
// a host whose assignment row is held was skipped, so the batch never reaches
// that host's lease: the lease pass cannot wait either.
// `markMigrationTargetRegistered` is the one writer that locks a migration row
// without the assignment row first. It takes no further locks, so it can delay a
// mid-migration row by up to the pool's lock_timeout but cannot close a cycle.
export function orderedControlRenewalRows<Row extends { identity: AssignmentIdentity }>(
rows: readonly Row[]
): Row[] {
return [...rows].sort(
(left, right) =>
left.identity.userId.localeCompare(right.identity.userId) ||
left.identity.relayHostId.localeCompare(right.identity.relayHostId)
)
}
// One statement renewing every due control lease on this cell, row-wise over the
// unnested parameter arrays. Logic per row is what the single-row predecessor
// did: lock the assignment, admit the caller's cell either as the current cell or
// as the source of an active forward migration, lock that host's control lease,
// push both expiries forward, and report one outcome. The one addition is
// `present_assignment`, an unlocked probe that separates a host with no
// assignment row at all from one whose row SKIP LOCKED passed over - the first
// closes the control, the second retries.
export const CONTROL_RENEWAL_BATCH_SQL = `WITH renewal_input AS MATERIALIZED (
SELECT
renewal.ordinality AS row_index,
renewal.user_id,
renewal.relay_host_id,
renewal.activity_id,
renewal.cell_id,
renewal.expires_at
FROM unnest(?::text[], ?::text[], ?::text[], ?::text[], ?::bigint[])
WITH ORDINALITY AS renewal(
user_id, relay_host_id, activity_id, cell_id, expires_at, ordinality
)
), present_assignment AS MATERIALIZED (
SELECT input.row_index
FROM renewal_input input
JOIN relay_assignments assignment
ON assignment.user_id = input.user_id
AND assignment.relay_host_id = input.relay_host_id
), assignment_state AS MATERIALIZED (
SELECT input.row_index, assignment.cell_id, assignment.assignment_epoch
FROM renewal_input input
JOIN relay_assignments assignment
ON assignment.user_id = input.user_id
AND assignment.relay_host_id = input.relay_host_id
ORDER BY assignment.user_id, assignment.relay_host_id
FOR UPDATE OF assignment SKIP LOCKED
), migration_state AS MATERIALIZED (
SELECT locked.row_index
FROM assignment_state locked
JOIN renewal_input input ON input.row_index = locked.row_index
JOIN relay_assignment_migrations migration
ON migration.user_id = input.user_id
AND migration.relay_host_id = input.relay_host_id
AND migration.source_cell_id = input.cell_id
AND migration.target_cell_id = locked.cell_id
AND migration.assignment_epoch = locked.assignment_epoch
AND migration.completed_at IS NULL AND migration.aborted_at IS NULL
ORDER BY migration.user_id, migration.relay_host_id
FOR UPDATE OF migration
), authorization_state AS MATERIALIZED (
SELECT locked.row_index
FROM assignment_state locked
JOIN renewal_input input ON input.row_index = locked.row_index
WHERE locked.cell_id = input.cell_id
OR EXISTS (
SELECT 1 FROM migration_state moving
WHERE moving.row_index = locked.row_index
)
), lease_state AS MATERIALIZED (
SELECT authorized.row_index, lease.activity_kind, lease.cell_id
FROM authorization_state authorized
JOIN renewal_input input ON input.row_index = authorized.row_index
JOIN relay_assignment_activity_leases lease
ON lease.user_id = input.user_id
AND lease.relay_host_id = input.relay_host_id
AND lease.activity_id = input.activity_id
ORDER BY lease.user_id, lease.relay_host_id, lease.activity_id
FOR UPDATE OF lease
), renewed_lease AS (
UPDATE relay_assignment_activity_leases lease
SET expires_at = GREATEST(lease.expires_at, input.expires_at),
updated_at = GREATEST(lease.updated_at, ?)
FROM lease_state state
JOIN renewal_input input ON input.row_index = state.row_index
WHERE lease.user_id = input.user_id
AND lease.relay_host_id = input.relay_host_id
AND lease.activity_id = input.activity_id
AND state.activity_kind = 'control' AND state.cell_id = input.cell_id
RETURNING state.row_index
), renewed_assignment AS (
-- Grouped per host: an UPDATE whose FROM offers a target row more than
-- once applies one source row and returns one, so two leases on one
-- host would leave the assignment carrying the wrong expiry. The
-- aggregate hands it exactly one row, carrying the later expiry.
UPDATE relay_assignments assignment
SET lease_expires_at = GREATEST(assignment.lease_expires_at, renewed.expires_at),
last_activity_at = GREATEST(assignment.last_activity_at, ?)
FROM (
SELECT input.user_id, input.relay_host_id, MAX(input.expires_at) AS expires_at
FROM renewed_lease renewed
JOIN renewal_input input ON input.row_index = renewed.row_index
GROUP BY input.user_id, input.relay_host_id
) renewed
WHERE assignment.user_id = renewed.user_id
AND assignment.relay_host_id = renewed.relay_host_id
RETURNING renewed.user_id
)
SELECT input.row_index, CASE
WHEN NOT EXISTS (
SELECT 1 FROM present_assignment present
WHERE present.row_index = input.row_index
) THEN 'assignment_not_found'
WHEN NOT EXISTS (
SELECT 1 FROM assignment_state locked WHERE locked.row_index = input.row_index
) THEN 'assignment_lock_unavailable'
WHEN NOT EXISTS (
SELECT 1 FROM authorization_state authorized
WHERE authorized.row_index = input.row_index
) THEN 'activity_cell_not_authoritative'
WHEN NOT EXISTS (
SELECT 1 FROM lease_state state WHERE state.row_index = input.row_index
) THEN 'control_activity_not_found'
WHEN EXISTS (
SELECT 1 FROM lease_state state
WHERE state.row_index = input.row_index
AND (state.activity_kind <> 'control' OR state.cell_id <> input.cell_id)
) THEN 'control_activity_moved'
-- Read from renewed_lease, which has one row per input row. The
-- assignment update collapses to one row per host, so it cannot answer
-- for a host that brought two leases to the same batch.
WHEN EXISTS (
SELECT 1 FROM renewed_lease renewed
WHERE renewed.row_index = input.row_index
) THEN 'renewed'
ELSE 'control_activity_not_found'
END AS outcome
FROM renewal_input input
ORDER BY input.row_index`
export function controlRenewalBatchParams(
rows: readonly ControlRenewalRequest[],
now: number
): unknown[] {
return [
rows.map((row) => row.identity.userId),
rows.map((row) => row.identity.relayHostId),
rows.map((row) => row.activityId),
rows.map((row) => row.cellId),
rows.map((row) => row.expiresAt),
now,
now
]
}
// Rows come back ordered by row_index, which is the 1-based position in the
// statement's parameter arrays.
export function readControlRenewalOutcomes(
rows: SqlRow[],
expected: number
): ControlRenewalOutcome[] {
if (rows.length !== expected) throw new Error('missing_control_renewal_outcome')
return rows.map((row, position) => {
if (Number(row.row_index) !== position + 1) {
throw new Error('misordered_control_renewal_outcome')
}
const outcome = row.outcome
if (
typeof outcome !== 'string' ||
!CONTROL_RENEWAL_STATEMENT_OUTCOMES.has(outcome as ControlRenewalOutcome)
) {
throw new Error('invalid_control_renewal_outcome')
}
// SAFETY: the membership check above is what narrows this string.
return outcome as ControlRenewalOutcome
})
}
@@ -12,6 +12,12 @@ import type WebSocket from 'ws'
import type { RelayAssignmentStore } from './assignment-store.js'
import type { RelayConfig } from './config.js'
import type { RelayCredentialStore } from './credential-store.js'
import { CONTROL_RENEWAL_BATCH_INTERVAL_MS } from './control-renewal-batch.js'
import {
CONTROL_RENEWAL_STATEMENT_OUTCOMES,
type ControlRenewalOutcome,
type ControlRenewalRequest
} from './control-renewal-statement.js'
import { HostSessionRegistry, type HostSession } from './host-session-registry.js'
import { relayHostLogDigest } from './relay-host-log-digest.js'
import type { RelayRuntimeObserver } from './relay-observability.js'
@@ -23,6 +29,12 @@ import {
import type { RelayTokenClaims } from './relay-token-verifier.js'
import { ProcessQueuedByteBudget } from './splice-forwarder.js'
// A due renewal leaves the heartbeat as a batch enqueue, so the store only sees
// the tick once the batch window closes.
async function closeRenewalWindow(): Promise<void> {
await vi.advanceTimersByTimeAsync(CONTROL_RENEWAL_BATCH_INTERVAL_MS)
}
class FakeSocket extends EventEmitter {
readonly OPEN = 1
readonly CLOSING = 2
@@ -109,6 +121,7 @@ function createRegistry(
activate: ActivateSession
acquireActivity: ReturnType<typeof vi.fn>
renewControlActivity: ReturnType<typeof vi.fn>
renewControlActivities: ReturnType<typeof vi.fn>
releaseActivity: ReturnType<typeof vi.fn>
observer: {
recordAuth: ReturnType<typeof vi.fn>
@@ -119,12 +132,38 @@ function createRegistry(
const acquireActivity = vi.fn().mockResolvedValue(undefined)
const renewControlActivity = vi.fn().mockResolvedValue(undefined)
const releaseActivity = vi.fn().mockResolvedValue(true)
// Mirrors the store's own batch semantics over the single-renewal mock: a known
// outcome becomes that row's verdict, and any other failure reaches the caller
// as the driver's error. Keeps every per-call expectation below aimed at the
// renewal a session actually asked for.
const renewControlActivities = vi.fn(
async (rows: readonly ControlRenewalRequest[]): Promise<ControlRenewalOutcome[]> =>
await Promise.all(
rows.map(async (row): Promise<ControlRenewalOutcome> => {
try {
await renewControlActivity(row.identity, {
activityId: row.activityId,
cellId: row.cellId,
expiresAt: row.expiresAt
})
return 'renewed'
} catch (error) {
const message = String((error as { message?: unknown }).message)
if (!CONTROL_RENEWAL_STATEMENT_OUTCOMES.has(message as ControlRenewalOutcome)) {
throw error
}
return message as ControlRenewalOutcome
}
})
)
)
const assignments = {
activateControl,
markMigrationTargetRegistered: vi.fn().mockResolvedValue(undefined),
resolve: vi.fn().mockResolvedValue({ cellId: config.cellId }),
acquireActivity,
renewControlActivity,
renewControlActivities,
releaseActivity
} as unknown as RelayAssignmentStore
const observer = {
@@ -176,6 +215,7 @@ function createRegistry(
activate,
acquireActivity,
renewControlActivity,
renewControlActivities,
releaseActivity,
observer
}
@@ -689,7 +729,8 @@ describe('host session cleanup races', () => {
expect(original).not.toBeNull()
await activate(new FakeSocket() as unknown as WebSocket, identity, original, 2, false, 1)
vi.advanceTimersByTime(15_000)
await vi.advanceTimersByTimeAsync(15_000)
await closeRenewalWindow()
expect(renewControlActivity).toHaveBeenCalledOnce()
expect(renewControlActivity).toHaveBeenCalledWith(
@@ -715,6 +756,7 @@ describe('host session cleanup races', () => {
})
)
await vi.advanceTimersByTimeAsync(15_000)
await closeRenewalWindow()
const replacement = new FakeSocket()
await h.activate(replacement as unknown as WebSocket, identity, session, 1, true, 1)
reject(new Error('activity_cell_not_authoritative'))
@@ -737,6 +779,7 @@ describe('host session cleanup races', () => {
})
)
await vi.advanceTimersByTimeAsync(15_000)
await closeRenewalWindow()
h.registry.drainHost({
attemptId: 'attempt',
userId: identity.sub,
@@ -762,6 +805,7 @@ describe('host session cleanup races', () => {
for (let interval = 0; interval < 4; interval++) {
await vi.advanceTimersByTimeAsync(RELAY_PROTOCOL_LIMITS.controlPingIntervalMs)
socket.emit('message', Buffer.from(JSON.stringify({ type: 'pong' })), false)
await closeRenewalWindow()
}
const pings = socket.send.mock.calls.filter((call) => String(call[0]).includes('"ping"'))
@@ -791,8 +835,10 @@ describe('host session cleanup races', () => {
try {
await activate(socket as unknown as WebSocket, identity, null, 1, false, 1)
await vi.advanceTimersByTimeAsync(RELAY_PROTOCOL_LIMITS.controlPingIntervalMs)
await closeRenewalWindow()
expect(renewControlActivity).toHaveBeenCalledOnce()
await vi.advanceTimersByTimeAsync(RELAY_PROTOCOL_LIMITS.controlPingIntervalMs)
await closeRenewalWindow()
expect(renewControlActivity).toHaveBeenCalledTimes(2)
} finally {
warn.mockRestore()
@@ -812,6 +858,7 @@ describe('host session cleanup races', () => {
await activate(socket as unknown as WebSocket, identity, null, 1, false, 1)
await vi.advanceTimersByTimeAsync(RELAY_PROTOCOL_LIMITS.controlPingIntervalMs * 2)
await closeRenewalWindow()
expect(renewControlActivity).toHaveBeenCalledTimes(2)
stalled.resolve(undefined)
@@ -831,13 +878,16 @@ describe('host session cleanup races', () => {
await activate(socket as unknown as WebSocket, identity, null, 1, false, 1)
await vi.advanceTimersByTimeAsync(RELAY_PROTOCOL_LIMITS.controlPingIntervalMs * 2)
await closeRenewalWindow()
expect(renewControlActivity).toHaveBeenCalledTimes(2)
stalled.resolve(undefined)
await vi.advanceTimersByTimeAsync(0)
await vi.advanceTimersByTimeAsync(RELAY_PROTOCOL_LIMITS.controlPingIntervalMs)
await closeRenewalWindow()
expect(renewControlActivity).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(RELAY_PROTOCOL_LIMITS.controlPingIntervalMs)
await closeRenewalWindow()
expect(renewControlActivity).toHaveBeenCalledTimes(3)
registry.drain(0)
@@ -855,6 +905,7 @@ describe('host session cleanup races', () => {
await activate(socket as unknown as WebSocket, identity, null, 1, false, 1)
await vi.advanceTimersByTimeAsync(RELAY_PROTOCOL_LIMITS.controlPingIntervalMs)
await closeRenewalWindow()
expect(acquireActivity).toHaveBeenCalledWith(
{ userId: identity.sub, relayHostId: identity.relayHostId },
@@ -880,6 +931,7 @@ describe('host session cleanup races', () => {
await activate(socket as unknown as WebSocket, identity, null, 1, false, 1)
await vi.advanceTimersByTimeAsync(RELAY_PROTOCOL_LIMITS.controlPingIntervalMs)
await closeRenewalWindow()
expect(acquireActivity).not.toHaveBeenCalled()
expect(socket.close).toHaveBeenCalledWith(RELAY_CLOSE_CODE.DRAINING, 'control activity moved')
@@ -899,6 +951,7 @@ describe('host session cleanup races', () => {
await activate(socket as unknown as WebSocket, identity, null, 1, false, 1)
await vi.advanceTimersByTimeAsync(RELAY_PROTOCOL_LIMITS.controlPingIntervalMs)
await closeRenewalWindow()
expect(socket.close).toHaveBeenCalledWith(
RELAY_CLOSE_CODE.DRAINING,
@@ -918,6 +971,7 @@ describe('host session cleanup races', () => {
await activate(socket as unknown as WebSocket, identity, null, 1, false, 1)
await vi.advanceTimersByTimeAsync(RELAY_PROTOCOL_LIMITS.controlPingIntervalMs)
await closeRenewalWindow()
expect(socket.close).toHaveBeenCalledWith(
RELAY_CLOSE_CODE.DRAINING,
@@ -939,6 +993,7 @@ describe('host session cleanup races', () => {
for (let interval = 0; interval < 3; interval++) {
await vi.advanceTimersByTimeAsync(RELAY_PROTOCOL_LIMITS.controlPingIntervalMs)
socket.emit('message', Buffer.from(JSON.stringify({ type: 'pong' })), false)
await closeRenewalWindow()
}
expect(renewControlActivity).toHaveBeenCalledTimes(2)
@@ -966,6 +1021,7 @@ describe('control renewal cadence across a rebind', () => {
const beat = async (target: FakeSocket): Promise<void> => {
await vi.advanceTimersByTimeAsync(ping)
target.emit('message', Buffer.from(JSON.stringify({ type: 'pong' })), false)
await closeRenewalWindow()
}
// Age the session so its attempt counter is well above zero.
@@ -993,6 +1049,57 @@ describe('control renewal cadence across a rebind', () => {
})
})
describe('control renewals shared by one batch', () => {
beforeEach(() => vi.useFakeTimers())
afterEach(() => {
vi.clearAllTimers()
vi.useRealTimers()
})
it('renews two due hosts in one call and leaves a stale one alone', async () => {
const activateControl = vi
.fn<RelayAssignmentStore['activateControl']>()
.mockResolvedValueOnce('control:production-gce-c3:1')
.mockResolvedValueOnce('control:production-gce-c3:1')
const { registry, activate, renewControlActivities } = createRegistry(activateControl)
const other = { ...identity, sub: 'user-2', relayHostId: 'ponmlkjihgfedcba' }
const staleSocket = new FakeSocket()
const liveSocket = new FakeSocket()
await activate(staleSocket as unknown as WebSocket, identity, null, 1, false, 1)
await activate(liveSocket as unknown as WebSocket, other, null, 1, false, 1)
const stale = registry.get({ userId: identity.sub, relayHostId: identity.relayHostId })!
const live = registry.get({ userId: other.sub, relayHostId: other.relayHostId })!
// Both come due inside the same window, and one socket goes away while the
// statement is still in PostgreSQL.
let release!: () => void
renewControlActivities.mockImplementationOnce(
async (rows: readonly ControlRenewalRequest[]) => {
staleSocket.close()
await new Promise<void>((resolve) => (release = resolve))
return rows.map((): ControlRenewalOutcome => 'renewed')
}
)
await vi.advanceTimersByTimeAsync(RELAY_PROTOCOL_LIMITS.controlPingIntervalMs)
const staleDueAt = stale.activityRenewalDueAt
await closeRenewalWindow()
release()
await vi.advanceTimersByTimeAsync(0)
expect(renewControlActivities).toHaveBeenCalledOnce()
expect(
renewControlActivities.mock.calls[0]![0].map(
(row: ControlRenewalRequest) => row.identity.relayHostId
)
).toEqual([identity.relayHostId, other.relayHostId])
expect(live.activityRenewalCompletedAttempt).toBe(1)
expect(stale.activityRenewalCompletedAttempt).toBe(0)
expect(stale.activityRenewalDueAt).toBe(staleDueAt)
registry.drain(0)
vi.advanceTimersByTime(0)
})
})
describe('control lease recovery after the session is gone', () => {
beforeEach(() => vi.useFakeTimers())
afterEach(() => {
@@ -1019,6 +1126,7 @@ describe('control lease recovery after the session is gone', () => {
new Promise<void>((_resolve, reject) => (failRenewal = reject))
)
await vi.advanceTimersByTimeAsync(RELAY_PROTOCOL_LIMITS.controlPingIntervalMs)
await closeRenewalWindow()
expect(renewControlActivity).toHaveBeenCalledOnce()
const newer = new FakeSocket()
+24 -9
View File
@@ -26,6 +26,7 @@ import type WebSocket from 'ws'
import type { RawData } from 'ws'
import type { RelayConfig } from './config.js'
import type { RelayAssignmentStore } from './assignment-store.js'
import { ControlRenewalBatch } from './control-renewal-batch.js'
import { RelayCredentialStore, type CredentialReservation } from './credential-store.js'
import { HostCloseReasonMemory } from './host-close-reason-memory.js'
import { relayHostLogDigest } from './relay-host-log-digest.js'
@@ -303,6 +304,15 @@ export class HostSessionRegistry {
private readonly cellIncarnation?: string
) {}
// Renewals leave the heartbeat as an enqueue: one statement per cell per
// window replaces one write transaction per host, which is what keeps the
// shared PostgreSQL instance out of buffer-header contention.
private readonly controlRenewals = new ControlRenewalBatch(
async (rows) => await this.assignments.renewControlActivities(rows),
() => this.logIdentity(),
(flush) => this.observer.recordControlRenewalFlush?.(flush)
)
// Uniform over [CONTROL_LEASE_MS - jitter, CONTROL_LEASE_MS + jitter).
private controlLeaseExpiresAt(): number {
const offset = Math.floor((this.random() * 2 - 1) * CONTROL_LEASE_JITTER_MS)
@@ -1384,15 +1394,13 @@ export class HostSessionRegistry {
session.controlActivityId === controlActivityId &&
session.authorityRevision === authorityRevision &&
attempt > session.activityRenewalCompletedAttempt
void this.assignments
.renewControlActivity(
{ userId: session.identity.sub, relayHostId: session.relayHostId },
{
activityId: controlActivityId,
cellId: this.config.cellId,
expiresAt: startedAt + CONTROL_ACTIVITY_LEASE_MS
}
)
void this.controlRenewals
.enqueue({
identity: { userId: session.identity.sub, relayHostId: session.relayHostId },
activityId: controlActivityId,
cellId: this.config.cellId,
expiresAt: startedAt + CONTROL_ACTIVITY_LEASE_MS
})
.then(() => {
if (!current()) return
session.activityRenewalCompletedAttempt = attempt
@@ -1458,6 +1466,13 @@ export class HostSessionRegistry {
session.socket?.close(RELAY_CLOSE_CODE.DRAINING, 'control activity moved')
return
}
if (error instanceof Error && error.message === 'assignment_lock_unavailable') {
// A per-host transaction held the row, so the batch passed over it
// rather than making every other host in the flush wait. The next
// tick is 15s away against a 105s lease, and the flush line already
// reports the count, so this needs no line of its own.
return
}
console.warn('[orca-relay] control activity renewal failed')
})
// Terminal handler: a throw inside the async catch above (e.g. a
@@ -1,6 +1,7 @@
import { monitorEventLoopDelay, performance } from 'node:perf_hooks'
import { RELAY_REGION_METRIC_SEGMENTS, type RelayRegion } from '@orca-cloud/relay-contract'
import type { ControlRenewalOutcome } from './assignment-store.js'
import type { ControlRenewalFlush } from './control-renewal-batch.js'
import type { CellInventoryHoldCounts } from './cell-inventory-hold-samples.js'
import type { PostgresPoolPressureCounts } from './postgres-pool-pressure.js'
import type { RelayReadinessGraceEvent, RelayReadinessObservation } from './relay-readiness.js'
@@ -53,6 +54,7 @@ export interface RelayRuntimeObserver {
recordReconnect(): void
recordSql(durationMs: number, success: boolean): void
recordControlRenewal?(durationMs: number, outcome: ControlRenewalOutcome): void
recordControlRenewalFlush?(flush: ControlRenewalFlush): void
recordControlActivityRecovery?(success: boolean): void
recordAssignmentAdmission?(outcome: AssignmentAdmissionOutcome): void
recordAssignmentRejectionReason?(lane: AssignmentAdmissionLane, reason: string): void
@@ -119,6 +121,8 @@ type RelayMetricDeltas = {
controlRttObserved: number
controlRenewalLatenciesMs: number[]
controlRenewalsByOutcome: Record<string, number>
controlRenewalFlushLatenciesMs: number[]
controlRenewalFlushRowsMax: number
controlActivityRecoveries: number
controlActivityRecoveryFailures: number
}
@@ -164,6 +168,8 @@ const emptyDeltas = (): RelayMetricDeltas => ({
controlRttObserved: 0,
controlRenewalLatenciesMs: [],
controlRenewalsByOutcome: {},
controlRenewalFlushLatenciesMs: [],
controlRenewalFlushRowsMax: 0,
controlActivityRecoveries: 0,
controlActivityRecoveryFailures: 0
})
@@ -274,6 +280,14 @@ export class RelayObservability implements RelayRuntimeObserver {
(this.deltas.controlRenewalsByOutcome[outcome] ?? 0) + 1
}
recordControlRenewalFlush(flush: ControlRenewalFlush): void {
this.deltas.controlRenewalFlushLatenciesMs.push(flush.durationMs)
this.deltas.controlRenewalFlushRowsMax = Math.max(
this.deltas.controlRenewalFlushRowsMax,
flush.rows
)
}
recordControlActivityRecovery(success: boolean): void {
if (success) this.deltas.controlActivityRecoveries++
else this.deltas.controlActivityRecoveryFailures++
@@ -379,6 +393,7 @@ export class RelayObservability implements RelayRuntimeObserver {
roundMs(percentile(deltas.clientAcceptStageSamplesMs[stage], 0.95))
const controlRtt = latencySummary(deltas.controlRttSamplesMs)
const controlRenewal = latencySummary(deltas.controlRenewalLatenciesMs)
const controlRenewalFlush = latencySummary(deltas.controlRenewalFlushLatenciesMs)
const memory = process.memoryUsage()
const p99 = this.eventLoop.count === 0 ? 0 : this.eventLoop.percentile(99) / 1_000_000
this.eventLoop.reset()
@@ -447,9 +462,16 @@ export class RelayObservability implements RelayRuntimeObserver {
deltas.controlRenewalsByOutcome.control_activity_not_found ?? 0,
controlActivityRecoveriesDelta: deltas.controlActivityRecoveries,
controlActivityRecoveryFailuresDelta: deltas.controlActivityRecoveryFailures,
// Meaning changed when renewals began batching: for a batched row this is
// the flush's duration, not that row's own statement latency. The
// per-flush fields below are the ones to read for statement cost.
controlRenewalLatencyMsP50: controlRenewal.p50,
controlRenewalLatencyMsP95: controlRenewal.p95,
controlRenewalLatencyMsMax: controlRenewal.max,
controlRenewalFlushesDelta: deltas.controlRenewalFlushLatenciesMs.length,
controlRenewalFlushRowsMax: deltas.controlRenewalFlushRowsMax,
controlRenewalFlushLatencyMsP95: controlRenewalFlush.p95,
controlRenewalFlushLatencyMsMax: controlRenewalFlush.max,
httpLatencyMsMax: roundMs(deltas.httpLatencyMsMax),
heapUsedBytes: memory.heapUsed,
heapTotalBytes: memory.heapTotal,
@@ -60,6 +60,10 @@ locals {
control_renewal_latency_ms_p50 = { field = "controlRenewalLatencyMsP50", description = "Control renewal latency p50 in the interval." }
control_renewal_latency_ms_p95 = { field = "controlRenewalLatencyMsP95", description = "Control renewal latency p95 in the interval." }
control_renewal_latency_ms_max = { field = "controlRenewalLatencyMsMax", description = "Maximum control renewal latency in the interval." }
control_renewal_flushes = { field = "controlRenewalFlushesDelta", description = "Batched control-renewal statements issued in the interval, one per cell per flush window." }
control_renewal_flush_rows_max = { field = "controlRenewalFlushRowsMax", description = "Largest number of hosts renewed by a single statement in the interval; the row ceiling is what bounds how long one flush holds its row locks." }
control_renewal_flush_ms_p95 = { field = "controlRenewalFlushLatencyMsP95", description = "Batched control-renewal statement duration p95 in the interval. Row locks live until the statement commits, so this is the lock hold." }
control_renewal_flush_ms_max = { field = "controlRenewalFlushLatencyMsMax", description = "Maximum batched control-renewal statement duration in the interval." }
control_renewals = { field = "controlRenewalsDelta", description = "Control renewal attempts in the interval." }
control_renewal_successes = { field = "controlRenewalSuccessesDelta", description = "Successful control renewals in the interval." }
control_renewal_lease_misses = { field = "controlRenewalLeaseMissesDelta", description = "Control renewals that found their activity lease missing." }