mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
* [comment audit] Align legacy failure-read counts with cached display * [comment audit] Condense shared provider lifecycle and browser recovery comments [skip ci] * [comment audit] Condense shared generation, path and preview comments [skip ci] * [comment audit] Condense host, file and Git contract rationale [skip ci] * [comment audit] Tighten Git lifecycle and provider contract comments [skip ci] * [comment audit] Clarify shared host authority and launch isolation [skip ci] * [comment audit] simplify local access mobile and native chat contracts [skip ci] * [comment audit] simplify native chat presentation and state contracts [skip ci] * [comment audit] simplify native runtime and provider contracts [skip ci] * [comment audit] simplify migration and orchestration contracts [skip ci] * [comment audit] simplify plugin trust and process capture rationale [skip ci] * [comment audit] simplify terminal delivery and remote compatibility rationale [skip ci] * [comment audit] clarify remote recovery and launch wire rationale [skip ci] * [comment audit] shorten shared RPC compatibility and runtime evidence comments [skip ci] * [comment audit] simplify secure storage shell startup and sidebar rationale [skip ci] * [comment audit] clarify sidebar, skill and SSH invariants [skip ci] * [comment audit] condense structured chat delivery and timing rationale [skip ci] * [comment audit] condense terminal replay and telemetry invariants [skip ci] * [comment audit] clarify launch permission and terminal delivery rationale [skip ci] * [comment audit] clarify workspace ownership and layout contracts [skip ci] * [comment audit] wait for fitted and painted SSH terminal links * [comment audit] clarify layout persistence and session ownership [skip ci] * [comment audit] clarify host routing and workspace lifecycle contracts [skip ci] * [comment audit] clarify startup persistence and app lifetime comments [skip ci] * [comment audit] clarify recovery and activity boundaries [skip ci] * [comment audit] condense activity and artifact invariants [skip ci] * [comment audit] condense automation authority contracts [skip ci] * [comment audit] simplify automation scheduling and history contracts [skip ci] * [comment audit] clarify browser chrome and annotation lifecycles [skip ci] * [comment audit] clarify browser routing and retained guest ownership [skip ci] * [comment audit] clarify browser recovery and document ownership comments [skip ci] * [comment audit] restore hosted runners required by native IME fixtures * [comment audit] shorten preview, palette and dashboard invariants [skip ci] * [comment audit] clarify terminal preview, dashboard and retention comments [skip ci] * [comment audit] clarify diff-note recovery and editor lifecycle comments [skip ci] * [comment audit] shorten rich-text and combined-diff ownership comments [skip ci] * [comment audit] shorten autosave, draft recovery and model ownership comments [skip ci] * [comment audit] shorten save-queue, notebook and Markdown constraints [skip ci] * [comment audit] shorten Monaco, PDF and rich Markdown safeguards [skip ci] * [comment audit] clarify rich Markdown source and selection safeguards [skip ci] * [comment audit] clarify editor loading and emulator stream lifetimes [skip ci] * [comment audit] trim emulator and feature tour narration [skip ci] * [comment audit] clarify floating workspace and GitHub cache ownership [skip ci] * [comment audit] trim provider controls and Linear filter explanations [skip ci] * [comment audit] clarify pairing and chat resume lifetimes [skip ci] * [comment audit] clarify chat delivery and transcript lifetimes [skip ci] * [comment audit] clarify session options and host-owned status [skip ci] * [comment audit] clarify draft durability and live-status precedence [skip ci] * [comment audit] Trim chat send and model ownership explanations [skip ci] * [comment audit] Shorten transcript ownership and send recovery comments [skip ci] * [comment audit] Simplify native chat composer and transcript hook comments [skip ci] * [comment audit] Shorten native chat scroll and structured session explanations [skip ci] * [comment audit] Trim workspace picker and onboarding commentary [skip ci] * [comment audit] Simplify pet rendering, review cache and quick open comments [skip ci] * [comment audit] Trim sidebar and session history explanations [skip ci] * [comment audit] Condense session resume and review checks comments [skip ci] * [comment audit] Shorten checks state and file explorer refresh comments [skip ci] * [comment audit] Trim plugin bridge and source-control action rationale [skip ci] * [comment audit] Trim source-control listing and review rationale [skip ci] * [comment audit] Shorten sidebar lifecycle and explorer safeguards [skip ci] * [comment audit] Condense Settings account and appearance rationale [skip ci] * [comment audit] Trim pairing and repository Settings explanations [skip ci] * [comment audit] Condense Settings search SSH and terminal safeguards [skip ci] * [comment audit] Shorten Settings voice and provider ownership comments [skip ci] * [comment audit] Condense Settings draft persistence and host-state rationale [skip ci] * [comment audit] Trim Settings hooks and sidebar setup rationale [skip ci] * [comment audit] Condense sidebar deletion and board-filter safeguards [skip ci] * [comment audit] Condense worktree cards, host ownership and Markdown contracts [skip ci] * [comment audit] Condense sidebar drag, folder and filtering contracts [skip ci] * [comment audit] Condense sidebar parity, sleep and host ownership contracts [skip ci] * [comment audit] Prune focus-read baseline after comment cleanup * [comment audit] Clarify sidebar visibility, board drops and agent evidence [skip ci] * [comment audit] Tighten sidebar listing, drag and reveal contracts [skip ci] * [comment audit] Clarify viewport geometry and skill update safeguards [skip ci] * [comment audit] Tighten skill inventory, usage and host status comments [skip ci] * [comment audit] Clarify usage ownership and resource kill safeguards [skip ci] * [comment audit] Tighten tab selection, rename and search contracts [skip ci] * [comment audit] Tighten tab-strip geometry and drag ownership comments [skip ci] * [comment audit] Tighten Tasks recovery, pagination and mutation comments [skip ci] * [comment audit] Tighten provider loading and terminal recovery comments [skip ci] * [comment audit] Tighten completion, cadence and PTY ownership comments [skip ci] * [comment audit] Clarify terminal restore and parked-pane boundaries [skip ci] * [comment audit] Tighten PTY startup and replay explanations [skip ci] * [comment audit] Clarify PTY identity and delivery ownership [skip ci] * [comment audit] Shorten remote reconnect and input replay rationale [skip ci] * [comment audit] Condense terminal parking clipboard and file link comments [skip ci] * [comment audit] Clarify IME ownership and trim trace history [skip ci] * [comment audit] Shorten pane recovery and parked watcher explanations [skip ci] * [comment audit] Condense snapshot replay and visibility recovery rationale [skip ci] * [comment audit] Shorten terminal parking and keyboard ownership rationale [skip ci] * [comment audit] Trim renderer lifecycle and menu rationale [skip ci] * [comment audit] Condense workspace cleanup ownership and settlement notes [skip ci] * [comment audit] Clarify renderer hook evidence and source ownership [skip ci] * [comment audit] Tighten IPC ownership and reconnect explanations [skip ci] * [comment audit] Trim renderer focus, paste and attention comments [skip ci] * [comment audit] Shorten scroll, locale and agent lifecycle comments [skip ci] * [comment audit] Trim agent launch, resume and ownership commentary [skip ci] * [comment audit] Shorten account, browser and editor comments [skip ci] * [comment audit] Clarify host-mirror waits, previews and input ownership [skip ci] * [comment audit] Condense launch recovery and editor tokenization comments [skip ci] * [comment audit] Condense workspace selection and terminal pane rationale [skip ci] * [comment audit] Condense terminal fitting and rendering recovery comments [skip ci] * [comment audit] Condense terminal input and host routing explanations [skip ci] * [comment audit] Condense ownership and sleeping-session recovery rationale [skip ci] * [comment audit] Condense structured launch and terminal link explanations [skip ci] * [comment audit] Condense terminal ownership and workspace file routing rationale [skip ci] * [comment audit] Condense session persistence and workspace activation rationale [skip ci] * [comment audit] Condense workspace routing and browser ownership rationale [skip ci] * [comment audit] Condense mirror recovery and layout authority rationale [skip ci] * [comment audit] Condense runtime transport and file safety rationale [skip ci] * [comment audit] Condense runtime graph and structured session rationale [skip ci] * [comment audit] Condense browser creation and remote tab recovery rationale [skip ci] * [comment audit] Condense snapshot authority and publisher recovery rationale [skip ci] * [comment audit] Condense frame, startup and provider cache rationale [skip ci] * [comment audit] Condense host twin and agent lifecycle rationale [skip ci] * [comment audit] Condense browser authority and editor cleanup rationale [skip ci] * [comment audit] Condense editor buffer identity and review cache rationale [skip ci] * [comment audit] Condense provider lifetime and repository ownership rationale [skip ci] * [comment audit] Condense reconnect, hydration and terminal retirement rationale [skip ci] * [comment audit] Condense recovery, reconciliation and persistence rationale [skip ci] * [comment audit] Condense cleanup ownership and listing admission rationale [skip ci] * [comment audit] Condense teardown ordering and terminal ownership rationale [skip ci] * [comment audit] Condense recovery ledger and web host-boundary rationale [skip ci] * [comment audit] Condense web reconnect and credential lifecycle rationale [skip ci] * [comment audit] Condense cloud rollout and assignment safety rationale [skip ci] * [comment audit] Condense relay session and database recovery rationale [skip ci] * [comment audit] Condense relay placement and protocol compatibility rationale [skip ci] * [comment audit] Condense build and packaging constraints [skip ci] * [comment audit] Condense CI gates and validation isolation rationale [skip ci] * [comment audit] Condense catalog identity and browser validation rationale [skip ci] * [comment audit] Condense mobile browser and frame-budget rationale [skip ci] * [comment audit] Shorten mobile route and browser test explanations [skip ci] * [comment audit] Condense editor and session seam test rationale [skip ci] * [comment audit] Trim session and terminal closure history [skip ci] * [comment audit] Shorten terminal browser and census explanations [skip ci] * [comment audit] Preserve root-only E2E diagnostic baseline * [comment audit] Condense mobile seam and HTML preview test rationale [skip ci] * [comment audit] Condense preview instrumentation and render harness rationale [skip ci] * [comment audit] Condense bundle contracts and native-build constraints [skip ci] * [comment audit] Shorten native packaging and CI routing rationale [skip ci] * [comment audit] Condense release tooling and benchmark constraints [skip ci] * [comment audit] Condense packaging and platform gate rationale [skip ci] * [comment audit] Shorten mobile route and history rationale [skip ci] * [comment audit] Condense mobile browser and component rationale [skip ci] * [comment audit] Shorten drawer and workspace-form rationale [skip ci] * [comment audit] Trim mobile review and editor-document explanations [skip ci] * [comment audit] Condense editor lifecycle and Markdown explanations [skip ci] * [comment audit] Trim mobile file, diagnostics and dictation commentary [skip ci] * [comment audit] Simplify mobile host-list and workspace-view explanations [skip ci] * [comment audit] Clarify mobile shell lifetime and bridge delivery constraints [skip ci] * [comment audit] Condense bridge stream, native request and audio rationale [skip ci] * [comment audit] Reduce bridge schema and page-client commentary [skip ci] * [comment audit] Tighten mobile page bridge contracts [skip ci] * [comment audit] Clarify mobile cache and session constraints [skip ci] * [comment audit] Condense mobile route and lifecycle rationale [skip ci] * [comment audit] Tighten mobile shell hook timing rationale [skip ci] * [comment audit] Clarify route handoff and notification compatibility [skip ci] * [comment audit] Clarify push registration and dictation cleanup constraints [skip ci] * [comment audit] Condense keyboard geometry and native media ownership rationale [skip ci] * [comment audit] Tighten mobile chat scroll and composer concurrency rationale [skip ci] * [comment audit] Condense mobile reply compatibility and upload ownership rationale [skip ci] * [comment audit] Condense optimistic echo and scroll ownership rationale [skip ci] * [comment audit] Tighten stream replay and queued session ownership rationale [skip ci] * [comment audit] Condense mobile composer and reply-schema constraints [skip ci] * [comment audit] Clarify mobile transcript recovery and session ownership [skip ci] * [comment audit] Clarify mobile queue and source-control compatibility [skip ci] * [comment audit] Condense storage and task launch constraints [skip ci] * [comment audit] Clarify task schemas and terminal document ownership [skip ci] * [comment audit] Tighten terminal input and viewport rationale [skip ci] * [comment audit] Shorten terminal and RPC recorder contracts [skip ci] * [comment audit] Condense recorder engine and connection contracts [skip ci] * [comment audit] Shorten host credential and relay recovery comments [skip ci] * [comment audit] Tighten pairing recovery and bundle transport rationale [skip ci] * [comment audit] Clarify RPC delivery and workspace catalog boundaries [skip ci] * [comment audit] Condense catalog and relay entry contracts [skip ci] * [comment audit] Shorten shutdown and SSH relay delivery contracts [skip ci] * [comment audit] Condense WSL relay and E2E regression rationale [skip ci] * [comment audit] Shorten cross-version harness and renderer fixture explanations [skip ci] * [comment audit] Condense paired-host and E2E lifecycle rationale [skip ci] * [comment audit] Shorten layout oracle and regression explanations [skip ci] * [comment audit] Condense paired recovery and ownership regression contracts [skip ci] * [comment audit] Shorten restart, sidebar and SSH lifecycle contracts [skip ci] * [comment audit] Condense terminal rendering and IME regression rationale [skip ci] * [comment audit] Shorten terminal recovery and benchmark contracts [skip ci] * [comment audit] Condense tooling and Windows lifecycle proof rationale [skip ci] * [comment audit] Condense workflow routing and release rationale [skip ci] * [comment audit] Condense mobile security and installer contracts [skip ci] * [comment audit] Shorten native rationale and stylesheet workarounds [skip ci] * [comment audit] Shorten Windows visibility probe rationale [skip ci] * [comment audit] Condense embedded release and signing rationale [skip ci] * [comment audit] Retain current main test behavior in final reconciliation [skip ci] * [comment audit] Complete final comment review across current source inventory * [comment audit] Handle consolidated audit reports before final CI * [comment audit] Preserve credential storage census detail [skip ci] * [comment audit] Scale broad changed E2E selections with verified timing shards * [comment audit] Align E2E assertions with browser close and settled session metadata * [comment audit] Match existing E2Es to current resource and windowing contracts * [comment audit] Observe the shared server outage notice in reconnect coverage * [comment audit] Make existing renderer probes measure settled tab and PTY work * [comment audit] Align palette E2E selector with task search * [comment audit] Refresh remaining palette search selectors * [comment audit] Keep selector correction within selected audit specs * [comment audit] Await emoji fixture settings inside renderer evaluation * [comment audit] Cancel fixture fit retries between terminal tests --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
931 lines
38 KiB
TypeScript
931 lines
38 KiB
TypeScript
import { ASSIGNMENT_LIMITS } from '@orca-cloud/relay-contract'
|
||
import { createHash } from 'node:crypto'
|
||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
|
||
import { RelayAssignmentRowBusyError, RelayAssignmentStore } from './assignment-store.js'
|
||
import {
|
||
encodeMembership,
|
||
type CellAdmissionMembership,
|
||
type CellAdmissionState
|
||
} from './cell-admission-selector.js'
|
||
import type { RelayCellConfig } from './config.js'
|
||
import { openRelayDatabase, type RelayDatabase } from './database.js'
|
||
|
||
const databaseUrl = process.env.ORCA_RELAY_TEST_POSTGRES_URL
|
||
const describePostgres = databaseUrl ? describe : describe.skip
|
||
|
||
const USER_PREFIX = 'isolated-replacement-postgres'
|
||
function databaseApplicationName(index: number): string {
|
||
return `${USER_PREFIX}-${process.pid}-${index}`
|
||
}
|
||
|
||
const NOW = 100
|
||
const CAPPED = {
|
||
capacityRequests: 1_000,
|
||
connectionHardCap: 600,
|
||
connectionUnobservedBound: 50
|
||
} as const
|
||
const ISOLATED: RelayCellConfig = {
|
||
id: 'isolated-replacement-source',
|
||
url: 'https://isolated-replacement-source.example.com',
|
||
region: 'us-central1',
|
||
...CAPPED
|
||
}
|
||
const TARGETS: RelayCellConfig[] = [
|
||
{
|
||
id: 'isolated-replacement-target-a',
|
||
url: 'https://isolated-replacement-target-a.example.com',
|
||
region: 'us-central1',
|
||
...CAPPED
|
||
},
|
||
{
|
||
id: 'isolated-replacement-target-b',
|
||
url: 'https://isolated-replacement-target-b.example.com',
|
||
region: 'us-central1',
|
||
...CAPPED
|
||
}
|
||
]
|
||
// The next tier: taken only when the source's region has no general room.
|
||
const OTHER_REGION: RelayCellConfig = {
|
||
id: 'isolated-replacement-other-region',
|
||
url: 'https://isolated-replacement-other-region.example.com',
|
||
region: 'asia-east2',
|
||
...CAPPED
|
||
}
|
||
const CELLS = [ISOLATED, ...TARGETS, OTHER_REGION]
|
||
const HOST_COUNT = 50
|
||
|
||
function hostIdentity(index: number): { userId: string; relayHostId: string } {
|
||
return {
|
||
userId: `${USER_PREFIX}-${index}`,
|
||
// relay host ids are fixed-width opaque ids.
|
||
relayHostId: `isolatedhost${String(index).padStart(4, '0')}`
|
||
}
|
||
}
|
||
|
||
describePostgres('PostgreSQL re-placement off a cell isolated for a roll', () => {
|
||
const databases: RelayDatabase[] = []
|
||
let stores: RelayAssignmentStore[] = []
|
||
|
||
async function reservedRequests(cellId: string): Promise<number> {
|
||
const rows = await databases[0]!.query(
|
||
`SELECT reserved_requests FROM relay_cells WHERE cell_id = ?`,
|
||
[cellId]
|
||
)
|
||
return Number(rows[0]!['reserved_requests'])
|
||
}
|
||
|
||
// The accounting invariant the migration paths assert: a cell's counter is
|
||
// exactly the units of the leases held on it.
|
||
async function expectReservationAccounting(): Promise<void> {
|
||
for (const cell of CELLS) {
|
||
const leased = await databases[0]!.query(
|
||
`SELECT COALESCE(SUM(request_units), 0) AS units
|
||
FROM relay_assignment_activity_leases WHERE cell_id = ?`,
|
||
[cell.id]
|
||
)
|
||
expect({ cell: cell.id, reserved: await reservedRequests(cell.id) }).toEqual({
|
||
cell: cell.id,
|
||
reserved: Number(leased[0]!['units'])
|
||
})
|
||
}
|
||
}
|
||
|
||
async function expectReservationAccountingExcept(cellId: string): Promise<void> {
|
||
for (const cell of CELLS.filter(({ id }) => id !== cellId)) {
|
||
const leased = await databases[0]!.query(
|
||
`SELECT COALESCE(SUM(request_units), 0) AS units
|
||
FROM relay_assignment_activity_leases WHERE cell_id = ?`,
|
||
[cell.id]
|
||
)
|
||
expect(await reservedRequests(cell.id)).toBe(Number(leased[0]!['units']))
|
||
}
|
||
}
|
||
|
||
async function counters(identity: {
|
||
userId: string
|
||
relayHostId: string
|
||
}): Promise<{ controls: number; splices: number }> {
|
||
const rows = await databases[0]!.query(
|
||
`SELECT reserved_controls, reserved_splices FROM relay_assignments
|
||
WHERE user_id = ? AND relay_host_id = ?`,
|
||
[identity.userId, identity.relayHostId]
|
||
)
|
||
return {
|
||
controls: Number(rows[0]!['reserved_controls']),
|
||
splices: Number(rows[0]!['reserved_splices'])
|
||
}
|
||
}
|
||
|
||
// Holds the named relay_cells rows in another session until released, the
|
||
// way a release from a far cell holds its row across a round trip.
|
||
async function holdCellRows(cellIds: string[]): Promise<() => Promise<void>> {
|
||
return await holdRows(
|
||
`SELECT cell_id FROM relay_cells WHERE cell_id IN (${cellIds.map(() => '?').join(', ')})
|
||
ORDER BY cell_id ASC`,
|
||
cellIds
|
||
)
|
||
}
|
||
|
||
async function holdRows(sql: string, params: unknown[]): Promise<() => Promise<void>> {
|
||
let release!: () => void
|
||
const released = new Promise<void>((resolve) => (release = resolve))
|
||
let held!: () => void
|
||
const acquired = new Promise<void>((resolve) => (held = resolve))
|
||
const holder = databases[3]!.transaction(async (transaction) => {
|
||
await transaction.queryLocked(sql, params)
|
||
held()
|
||
await released
|
||
})
|
||
await acquired
|
||
return async () => {
|
||
release()
|
||
await holder
|
||
}
|
||
}
|
||
|
||
async function waitForLock(table: 'relay_cells' | 'relay_assignments'): Promise<void> {
|
||
await vi.waitFor(async () => {
|
||
const rows = await databases[0]!.query(
|
||
`SELECT COUNT(*) AS waiting FROM pg_stat_activity
|
||
WHERE datname = current_database() AND wait_event_type = 'Lock'
|
||
AND application_name = ? AND query LIKE ? AND query LIKE '%FOR UPDATE%'`,
|
||
[databaseApplicationName(1), `%${table}%`]
|
||
)
|
||
expect(Number(rows[0]?.['waiting'])).toBeGreaterThan(0)
|
||
}, { timeout: 4_000, interval: 10 })
|
||
}
|
||
|
||
// Puts a host on the source with a live control and a splice, then isolates
|
||
// the source for a roll with both targets open.
|
||
async function seedActiveHostOnIsolatedSource(index: number): Promise<{
|
||
identity: { userId: string; relayHostId: string }
|
||
first: { cellId: string; assignmentEpoch: number }
|
||
}> {
|
||
const identity = hostIdentity(index)
|
||
await applySelector({ [TARGETS[0]!.id]: 'migration-only', [TARGETS[1]!.id]: 'migration-only' })
|
||
const first = await stores[0]!.assign(identity, 'us-central1')
|
||
expect(first.cellId).toBe(ISOLATED.id)
|
||
await stores[0]!.activateControl(identity, {
|
||
cellId: ISOLATED.id,
|
||
assignmentEpoch: first.assignmentEpoch,
|
||
generation: 1
|
||
})
|
||
await stores[0]!.acquireActivity(identity, {
|
||
activityId: 'splice:keep-1',
|
||
kind: 'splice',
|
||
cellId: ISOLATED.id
|
||
})
|
||
await applySelector({ [TARGETS[0]!.id]: 'general', [TARGETS[1]!.id]: 'general' })
|
||
await applySelector({ [ISOLATED.id]: 'migration-only' }, [ISOLATED.id])
|
||
return { identity, first }
|
||
}
|
||
|
||
// `enforced` puts a cell at its connection cap, which leaves it no headroom.
|
||
async function heartbeatAll(enforced: Record<string, number> = {}): Promise<void> {
|
||
for (const [index, cell] of CELLS.entries()) {
|
||
await stores[0]!.recordCellHeartbeat({
|
||
cellId: cell.id,
|
||
cellUrl: cell.url,
|
||
cellIncarnation: `1111111${index}-1111-4111-8111-111111111111`,
|
||
startedAt: 50,
|
||
ready: true,
|
||
observedRequests: 0,
|
||
region: cell.region,
|
||
// The cell checks enforced = total + in-flight + reserved.
|
||
totalConnections: enforced[cell.id] ?? 0,
|
||
inFlightConnections: 0,
|
||
reservedConnectionUnits: 0,
|
||
enforcedConnectionUnits: enforced[cell.id] ?? 0,
|
||
connectionHardCap: 600,
|
||
connectionUnobservedBound: 50
|
||
})
|
||
}
|
||
}
|
||
|
||
// The real isolate path: one selector apply, generation CAS and all, naming
|
||
// the cells it stamps. Nothing else in the fleet may write the stamp.
|
||
async function applySelector(
|
||
states: Record<string, CellAdmissionState>,
|
||
rollIsolatedCells?: string[]
|
||
): Promise<void> {
|
||
const current = await stores[0]!.inspectCellAdmissionSelector()
|
||
// Built from relay_cells, not from the selector's own membership: the apply
|
||
// requires exact coverage of the fleet, and this database is shared.
|
||
const fleet = await databases[0]!.query(
|
||
`SELECT cell_id FROM relay_cells ORDER BY cell_id ASC`
|
||
)
|
||
const membership: CellAdmissionMembership = {
|
||
existingOnly: [],
|
||
migrationOnly: [],
|
||
general: []
|
||
}
|
||
for (const row of fleet) {
|
||
const cellId = String(row['cell_id'])
|
||
const state =
|
||
states[cellId] ??
|
||
(current.selector.membership.existingOnly.includes(cellId)
|
||
? 'existing-only'
|
||
: current.selector.membership.migrationOnly.includes(cellId)
|
||
? 'migration-only'
|
||
: 'general')
|
||
if (state === 'existing-only') membership.existingOnly.push(cellId)
|
||
else if (state === 'migration-only') membership.migrationOnly.push(cellId)
|
||
else membership.general.push(cellId)
|
||
}
|
||
await stores[0]!.applyCellAdmissionSelector({
|
||
// The id is capped at 128 characters; the generation already makes it unique.
|
||
attemptId: `isolated-${current.selector.generation}-${createHash('sha256')
|
||
.update(Object.keys(states).join('-'))
|
||
.digest('hex')
|
||
.slice(0, 16)}`,
|
||
expectedGeneration: current.selector.generation,
|
||
...(current.selector.generation === 0
|
||
? {
|
||
expectedMembershipSha256: createHash('sha256')
|
||
.update(encodeMembership(current.selector.membership))
|
||
.digest('hex')
|
||
}
|
||
: {}),
|
||
membership,
|
||
...(rollIsolatedCells ? { rollIsolatedCells } : {})
|
||
})
|
||
}
|
||
|
||
async function rollIsolatedAt(cellId: string): Promise<number | null> {
|
||
const rows = await databases[0]!.query(
|
||
`SELECT roll_isolated_at FROM relay_cell_admission WHERE cell_id = ?`,
|
||
[cellId]
|
||
)
|
||
const value = rows[0]?.['roll_isolated_at']
|
||
return value === undefined || value === null ? null : Number(value)
|
||
}
|
||
|
||
// Every case starts from the whole fleet general; a case that leaves a cell
|
||
// isolated would otherwise starve the next one of placement candidates.
|
||
async function resetFleet(): Promise<void> {
|
||
await deleteHostRows()
|
||
// Only this file's hosts hold leases on these cells, so zero is exact.
|
||
await databases[0]!.query(
|
||
`UPDATE relay_cells SET reserved_requests = 0
|
||
WHERE cell_id IN (${CELLS.map(() => '?').join(', ')})`,
|
||
CELLS.map((cell) => cell.id)
|
||
)
|
||
await applySelector(Object.fromEntries(CELLS.map((cell) => [cell.id, 'general'])))
|
||
}
|
||
|
||
// Shared PostgreSQL suites use generation-0 helpers; restore the exact selector boundary or later files fail admission_selector_boundary_active.
|
||
async function resetSelectorBoundary(): Promise<void> {
|
||
await databases[0]!.query(
|
||
`UPDATE relay_admission_selectors SET generation = 0, attempt_id = NULL
|
||
WHERE selector_id = 'general'`
|
||
)
|
||
await databases[0]!.query(
|
||
`DELETE FROM relay_admission_selector_intents WHERE attempt_id LIKE 'isolated-%'`
|
||
)
|
||
// Rewrites membership_json from the live fleet, which generation 0 allows.
|
||
await stores[0]!.reconcileCells([], false)
|
||
}
|
||
|
||
async function deleteHostRows(): Promise<void> {
|
||
for (const table of [
|
||
'relay_control_connection_reservations',
|
||
'relay_assignment_activity_leases',
|
||
'relay_assignment_migrations',
|
||
'relay_assignments'
|
||
]) {
|
||
await databases[0]!.query(`DELETE FROM ${table} WHERE user_id LIKE '${USER_PREFIX}-%'`)
|
||
}
|
||
}
|
||
|
||
beforeAll(async () => {
|
||
// Four connections so the concurrent dials below really contend on the
|
||
// fleet-wide relay_cells lock rather than queueing in one client.
|
||
for (let index = 0; index < 4; index += 1) {
|
||
databases.push(await openRelayDatabase({
|
||
databaseUrl,
|
||
dataDir: '',
|
||
applicationName: databaseApplicationName(index)
|
||
}))
|
||
}
|
||
stores = databases.map(
|
||
(database) =>
|
||
new RelayAssignmentStore(database, () => NOW, {
|
||
requireLiveCells: true,
|
||
heartbeatTtlMs: 45_000
|
||
})
|
||
)
|
||
await deleteHostRows()
|
||
// Registers the cell rows without touching admission, so this works at any
|
||
// selector generation the shared database happens to be sitting at.
|
||
await stores[0]!.reconcileCells(CELLS, false)
|
||
await resetSelectorBoundary()
|
||
await heartbeatAll()
|
||
})
|
||
|
||
afterAll(async () => {
|
||
if (databases[0]) {
|
||
await deleteHostRows()
|
||
// Drop cell rows before rebuilding membership or later reads fail admission_selector_membership_drift.
|
||
for (const cell of CELLS) {
|
||
for (const table of [
|
||
'relay_cell_connection_snapshots',
|
||
'relay_cell_connection_runtime',
|
||
'relay_cell_connection_limits',
|
||
'relay_cell_runtime',
|
||
'relay_cell_admission',
|
||
'relay_cell_regions',
|
||
'relay_cells'
|
||
]) {
|
||
await databases[0].query(`DELETE FROM ${table} WHERE cell_id = ?`, [cell.id])
|
||
}
|
||
}
|
||
await resetSelectorBoundary()
|
||
}
|
||
for (const connection of databases) await connection.close()
|
||
})
|
||
|
||
it('stamps only the named cell on isolate and clears it on restore', async () => {
|
||
await resetFleet()
|
||
expect(await rollIsolatedAt(ISOLATED.id)).toBeNull()
|
||
|
||
await applySelector({ [ISOLATED.id]: 'migration-only' }, [ISOLATED.id])
|
||
const stamped = await rollIsolatedAt(ISOLATED.id)
|
||
expect(stamped).toBe(NOW)
|
||
// Cells the isolate did not name stay unmarked even when parked in the same
|
||
// apply: that is every non-roll flow, and its hosts must keep their pin.
|
||
await applySelector({ [TARGETS[0]!.id]: 'migration-only' })
|
||
expect(await rollIsolatedAt(TARGETS[0]!.id)).toBeNull()
|
||
|
||
// A failed wave re-isolates rather than restoring; the stamp survives, and
|
||
// does not move, so the hosts left behind stay eligible.
|
||
await applySelector({ [ISOLATED.id]: 'migration-only' }, [ISOLATED.id])
|
||
expect(await rollIsolatedAt(ISOLATED.id)).toBe(stamped)
|
||
|
||
// Restore writes 'general', and the same statement clears the stamp.
|
||
await applySelector({ [ISOLATED.id]: 'general' })
|
||
expect(await rollIsolatedAt(ISOLATED.id)).toBeNull()
|
||
}, 30_000)
|
||
|
||
it('classifies a reconnect by its home cell’s stamp in the verification read', async () => {
|
||
await resetFleet()
|
||
const identity = hostIdentity(903)
|
||
const classify = { classifyHomeRollIsolation: true }
|
||
await applySelector({ [TARGETS[0]!.id]: 'migration-only', [TARGETS[1]!.id]: 'migration-only' })
|
||
const first = await stores[0]!.assign(identity, 'us-central1')
|
||
await applySelector({ [TARGETS[0]!.id]: 'general', [TARGETS[1]!.id]: 'general' })
|
||
expect(first.cellId).toBe(ISOLATED.id)
|
||
expect(await stores[0]!.resolve(identity, classify)).not.toHaveProperty('homeCellRollIsolated')
|
||
|
||
await applySelector({ [ISOLATED.id]: 'migration-only' }, [ISOLATED.id])
|
||
expect(await stores[0]!.resolve(identity, classify)).toMatchObject({
|
||
cellId: ISOLATED.id,
|
||
homeCellRollIsolated: true
|
||
})
|
||
|
||
await applySelector({ [ISOLATED.id]: 'general' })
|
||
expect(await stores[0]!.resolve(identity, classify)).not.toHaveProperty('homeCellRollIsolated')
|
||
}, 30_000)
|
||
|
||
it('ignores a stamp older than the roll it is supposed to describe', async () => {
|
||
// A failed wave keeps its stamp on purpose and can sit for hours; past the
|
||
// bound the cell stops shedding hosts one dial at a time.
|
||
await resetFleet()
|
||
const identity = hostIdentity(902)
|
||
const first = await stores[0]!.assign(identity, 'us-central1')
|
||
await applySelector({ [ISOLATED.id]: 'migration-only' }, [ISOLATED.id])
|
||
await databases[0]!.query(
|
||
`UPDATE relay_cell_admission SET roll_isolated_at = ? WHERE cell_id = ?`,
|
||
[NOW - (2 * 60 * 60_000 + 1), ISOLATED.id]
|
||
)
|
||
|
||
expect(await stores[0]!.assign(identity, 'us-central1')).toMatchObject({
|
||
cellId: first.cellId,
|
||
assignmentEpoch: first.assignmentEpoch
|
||
})
|
||
}, 30_000)
|
||
|
||
it('re-places every host off an isolated cell without leaking a reservation', async () => {
|
||
await resetFleet()
|
||
const identities = Array.from({ length: HOST_COUNT }, (_, index) => hostIdentity(index))
|
||
const sourceBaseline = await reservedRequests(ISOLATED.id)
|
||
const targetBaseline =
|
||
(await reservedRequests(TARGETS[0]!.id)) + (await reservedRequests(TARGETS[1]!.id))
|
||
|
||
// Everyone lands on the cell about to be isolated.
|
||
await applySelector({ [TARGETS[0]!.id]: 'migration-only', [TARGETS[1]!.id]: 'migration-only' })
|
||
const first = new Map<string, { cellId: string; assignmentEpoch: number }>()
|
||
for (const identity of identities) {
|
||
const grant = await stores[0]!.assign(identity, 'us-central1')
|
||
expect(grant.cellId).toBe(ISOLATED.id)
|
||
first.set(identity.relayHostId, grant)
|
||
}
|
||
await applySelector({ [TARGETS[0]!.id]: 'general', [TARGETS[1]!.id]: 'general' })
|
||
|
||
// The isolate step. The state and the stamp are written by one UPDATE under
|
||
// the fleet-wide relay_cells lock, so there is no torn state to race against.
|
||
await applySelector({ [ISOLATED.id]: 'migration-only' }, [ISOLATED.id])
|
||
expect(await rollIsolatedAt(ISOLATED.id)).not.toBeNull()
|
||
|
||
const grants = await Promise.all(
|
||
identities.map(
|
||
async (identity, index) =>
|
||
await stores[1 + (index % (stores.length - 1))]!.assign(identity, 'us-central1')
|
||
)
|
||
)
|
||
|
||
for (const [index, grant] of grants.entries()) {
|
||
const identity = identities[index]!
|
||
expect(grant.cellId).not.toBe(ISOLATED.id)
|
||
expect(TARGETS.map(({ id }) => id)).toContain(grant.cellId)
|
||
// Exactly once: a double bump would mean two transactions both moved it.
|
||
expect(grant.assignmentEpoch).toBe(first.get(identity.relayHostId)!.assignmentEpoch + 1)
|
||
}
|
||
|
||
// The placement leaves the source row alone: each host's pending control
|
||
// there still counts until the source releases it.
|
||
expect(await reservedRequests(ISOLATED.id)).toBe(sourceBaseline + HOST_COUNT)
|
||
expect(
|
||
(await reservedRequests(TARGETS[0]!.id)) + (await reservedRequests(TARGETS[1]!.id))
|
||
).toBe(targetBaseline + HOST_COUNT)
|
||
await expectReservationAccounting()
|
||
for (const identity of identities) {
|
||
expect(
|
||
await stores[0]!.releaseActivity(
|
||
identity,
|
||
`control-pending:${first.get(identity.relayHostId)!.assignmentEpoch}`
|
||
)
|
||
).toBe(true)
|
||
}
|
||
expect(await reservedRequests(ISOLATED.id)).toBe(sourceBaseline)
|
||
await expectReservationAccounting()
|
||
|
||
const rows = await databases[0]!.query(
|
||
`SELECT COUNT(*) AS count FROM relay_assignments
|
||
WHERE user_id LIKE '${USER_PREFIX}-%' AND cell_id = ?`,
|
||
[ISOLATED.id]
|
||
)
|
||
expect(Number(rows[0]!['count'])).toBe(0)
|
||
}, 60_000)
|
||
|
||
it('lets exactly one of a host’s racing dials win the re-placement', async () => {
|
||
await resetFleet()
|
||
const identity = hostIdentity(900)
|
||
await applySelector({ [TARGETS[0]!.id]: 'migration-only', [TARGETS[1]!.id]: 'migration-only' })
|
||
const first = await stores[0]!.assign(identity, 'us-central1')
|
||
expect(first.cellId).toBe(ISOLATED.id)
|
||
await applySelector({ [TARGETS[0]!.id]: 'general', [TARGETS[1]!.id]: 'general' })
|
||
const targetBaseline =
|
||
(await reservedRequests(TARGETS[0]!.id)) + (await reservedRequests(TARGETS[1]!.id))
|
||
const sourceBefore = await reservedRequests(ISOLATED.id)
|
||
|
||
await applySelector({ [ISOLATED.id]: 'migration-only' }, [ISOLATED.id])
|
||
|
||
// Two dials from the same host, on separate connections, through the same
|
||
// sticky lane. The per-assignment row lock is what must serialise them.
|
||
const raced = await Promise.allSettled([
|
||
stores[1]!.assign(identity, 'us-central1'),
|
||
stores[2]!.assign(identity, 'us-central1')
|
||
])
|
||
const granted = raced.flatMap((result) =>
|
||
result.status === 'fulfilled' ? [result.value] : []
|
||
)
|
||
expect(granted.length).toBeGreaterThan(0)
|
||
|
||
// Concurrent dials may grant repeatedly but commit one move: one epoch/unit on target, preserving source leases until release.
|
||
const settled = await stores[0]!.resolve(identity)
|
||
expect(settled?.assignmentEpoch).toBe(first.assignmentEpoch + 1)
|
||
expect(settled?.cellId).not.toBe(ISOLATED.id)
|
||
for (const grant of granted) expect(grant.cellId).toBe(settled?.cellId)
|
||
expect(await reservedRequests(ISOLATED.id)).toBe(sourceBefore)
|
||
expect(
|
||
(await reservedRequests(TARGETS[0]!.id)) + (await reservedRequests(TARGETS[1]!.id))
|
||
).toBe(targetBaseline + 1)
|
||
}, 30_000)
|
||
|
||
it('grants no host the isolated cell after the admission flip commits', async () => {
|
||
await resetFleet()
|
||
const identity = hostIdentity(901)
|
||
const first = await stores[0]!.assign(identity, 'us-central1')
|
||
await applySelector({ [ISOLATED.id]: 'migration-only' }, [ISOLATED.id])
|
||
|
||
for (let dial = 0; dial < 5; dial += 1) {
|
||
const grant = await stores[1 + (dial % (stores.length - 1))]!.assign(
|
||
identity,
|
||
'us-central1'
|
||
)
|
||
expect(grant.cellId).not.toBe(ISOLATED.id)
|
||
}
|
||
// Only the first dial re-places; the rest are ordinary sticky re-grants.
|
||
expect((await stores[0]!.resolve(identity))?.assignmentEpoch).toBe(
|
||
first.assignmentEpoch + 1
|
||
)
|
||
}, 30_000)
|
||
it('leaves the source row to the source’s own releases and keeps the host’s counters', async () => {
|
||
await resetFleet()
|
||
const { identity, first } = await seedActiveHostOnIsolatedSource(910)
|
||
const sourceBefore = await reservedRequests(ISOLATED.id)
|
||
const targetsBefore =
|
||
(await reservedRequests(TARGETS[0]!.id)) + (await reservedRequests(TARGETS[1]!.id))
|
||
expect(sourceBefore).toBe(3)
|
||
expect(await counters(identity)).toEqual({ controls: 1, splices: 1 })
|
||
|
||
const moved = await stores[1]!.assign(identity, 'us-central1')
|
||
expect(TARGETS.map(({ id }) => id)).toContain(moved.cellId)
|
||
expect(moved.assignmentEpoch).toBe(first.assignmentEpoch + 1)
|
||
expect(await reservedRequests(ISOLATED.id)).toBe(sourceBefore)
|
||
expect(
|
||
(await reservedRequests(TARGETS[0]!.id)) + (await reservedRequests(TARGETS[1]!.id))
|
||
).toBe(targetsBefore + 1)
|
||
// One control added on the target; the source's control and splice kept.
|
||
expect(await counters(identity)).toEqual({ controls: 2, splices: 1 })
|
||
await expectReservationAccounting()
|
||
|
||
// The source closes the host's sockets: each release takes its own units.
|
||
expect(await stores[0]!.releaseActivity(identity, `control:${ISOLATED.id}:1`)).toBe(true)
|
||
expect(await reservedRequests(ISOLATED.id)).toBe(sourceBefore - 1)
|
||
expect(await counters(identity)).toEqual({ controls: 1, splices: 1 })
|
||
expect(await stores[0]!.releaseActivity(identity, 'splice:keep-1')).toBe(true)
|
||
expect(await reservedRequests(ISOLATED.id)).toBe(0)
|
||
// The target's control survives the source's releases.
|
||
expect(await counters(identity)).toEqual({ controls: 1, splices: 0 })
|
||
await expectReservationAccounting()
|
||
}, 30_000)
|
||
|
||
it('re-places an isolated incumbent while the source row is held', async () => {
|
||
await resetFleet()
|
||
const { identity, first } = await seedActiveHostOnIsolatedSource(911)
|
||
const sourceBefore = await reservedRequests(ISOLATED.id)
|
||
const release = await holdCellRows([ISOLATED.id])
|
||
try {
|
||
// Held far past the 500ms bounded wait: a placement that asked for the
|
||
// source row could not finish inside this window.
|
||
const moved = await Promise.race([
|
||
stores[1]!.assign(identity, 'us-central1'),
|
||
new Promise<'blocked'>((resolve) => setTimeout(() => resolve('blocked'), 3_000))
|
||
])
|
||
expect(moved).not.toBe('blocked')
|
||
expect(moved).toMatchObject({ assignmentEpoch: first.assignmentEpoch + 1 })
|
||
} finally {
|
||
await release()
|
||
}
|
||
expect(await reservedRequests(ISOLATED.id)).toBe(sourceBefore)
|
||
await expectReservationAccounting()
|
||
}, 30_000)
|
||
|
||
it('waits for the target rows it increments', async () => {
|
||
await resetFleet()
|
||
const { identity, first } = await seedActiveHostOnIsolatedSource(912)
|
||
const targetsBefore =
|
||
(await reservedRequests(TARGETS[0]!.id)) + (await reservedRequests(TARGETS[1]!.id))
|
||
const release = await holdCellRows(TARGETS.map(({ id }) => id))
|
||
const placement = stores[1]!.assign(identity, 'us-central1')
|
||
try {
|
||
await waitForLock('relay_cells')
|
||
} finally {
|
||
await release()
|
||
await placement.catch(() => undefined)
|
||
}
|
||
expect(await placement).toMatchObject({ assignmentEpoch: first.assignmentEpoch + 1 })
|
||
expect(
|
||
(await reservedRequests(TARGETS[0]!.id)) + (await reservedRequests(TARGETS[1]!.id))
|
||
).toBe(targetsBefore + 1)
|
||
await expectReservationAccounting()
|
||
}, 30_000)
|
||
|
||
it('keeps the pin when no general cell in any region has headroom', async () => {
|
||
await resetFleet()
|
||
const { identity, first } = await seedActiveHostOnIsolatedSource(913)
|
||
const sourceBefore = await reservedRequests(ISOLATED.id)
|
||
await databases[0]!.query(
|
||
`UPDATE relay_cells SET reserved_requests = capacity_requests WHERE cell_id IN (?, ?, ?)`,
|
||
[...TARGETS, OTHER_REGION].map(({ id }) => id)
|
||
)
|
||
try {
|
||
expect(await stores[1]!.assign(identity, 'us-central1')).toMatchObject({
|
||
cellId: ISOLATED.id,
|
||
assignmentEpoch: first.assignmentEpoch
|
||
})
|
||
expect(await reservedRequests(ISOLATED.id)).toBe(sourceBefore)
|
||
expect(await counters(identity)).toEqual({ controls: 1, splices: 1 })
|
||
} finally {
|
||
await databases[0]!.query(
|
||
`UPDATE relay_cells SET reserved_requests = 0 WHERE cell_id IN (?, ?, ?)`,
|
||
[...TARGETS, OTHER_REGION].map(({ id }) => id)
|
||
)
|
||
}
|
||
}, 30_000)
|
||
|
||
it('moves to another region when its own is at the connection cap, without the source row', async () => {
|
||
await resetFleet()
|
||
const { identity, first } = await seedActiveHostOnIsolatedSource(917)
|
||
const sourceBefore = await reservedRequests(ISOLATED.id)
|
||
await heartbeatAll(Object.fromEntries(TARGETS.map(({ id }) => [id, 600])))
|
||
const release = await holdCellRows([ISOLATED.id])
|
||
try {
|
||
const moved = await Promise.race([
|
||
stores[1]!.assign(identity, 'us-central1'),
|
||
new Promise<'blocked'>((resolve) => setTimeout(() => resolve('blocked'), 3_000))
|
||
])
|
||
expect(moved).toMatchObject({
|
||
cellId: OTHER_REGION.id,
|
||
region: 'asia-east2',
|
||
assignmentEpoch: first.assignmentEpoch + 1
|
||
})
|
||
} finally {
|
||
await release()
|
||
await heartbeatAll()
|
||
}
|
||
expect(await reservedRequests(ISOLATED.id)).toBe(sourceBefore)
|
||
expect(await reservedRequests(OTHER_REGION.id)).toBe(1)
|
||
await expectReservationAccounting()
|
||
}, 30_000)
|
||
it('places a dormant host without asking for its old cell row', async () => {
|
||
await resetFleet()
|
||
const identity = hostIdentity(914)
|
||
await applySelector({ [TARGETS[0]!.id]: 'migration-only', [TARGETS[1]!.id]: 'migration-only' })
|
||
const first = await stores[0]!.assign(identity, 'us-central1')
|
||
expect(first.cellId).toBe(ISOLATED.id)
|
||
// Dormant: no units held and no activity inside the dormancy window.
|
||
await databases[0]!.query(
|
||
`DELETE FROM relay_assignment_activity_leases WHERE user_id = ? AND relay_host_id = ?`,
|
||
[identity.userId, identity.relayHostId]
|
||
)
|
||
await databases[0]!.query(
|
||
`UPDATE relay_assignments SET reserved_controls = 0, last_activity_at = ?
|
||
WHERE user_id = ? AND relay_host_id = ?`,
|
||
[NOW - ASSIGNMENT_LIMITS.dormantTtlMs, identity.userId, identity.relayHostId]
|
||
)
|
||
await databases[0]!.query(`UPDATE relay_cells SET reserved_requests = 0 WHERE cell_id = ?`, [
|
||
ISOLATED.id
|
||
])
|
||
await applySelector({ [TARGETS[0]!.id]: 'general', [TARGETS[1]!.id]: 'general' })
|
||
await applySelector({ [ISOLATED.id]: 'migration-only' }, [ISOLATED.id])
|
||
|
||
const release = await holdCellRows([ISOLATED.id])
|
||
try {
|
||
const moved = await Promise.race([
|
||
stores[1]!.assign(identity, 'us-central1'),
|
||
new Promise<'blocked'>((resolve) => setTimeout(() => resolve('blocked'), 3_000))
|
||
])
|
||
expect(moved).toMatchObject({ assignmentEpoch: first.assignmentEpoch + 1 })
|
||
expect(TARGETS.map(({ id }) => id)).toContain(moved === 'blocked' ? moved : moved.cellId)
|
||
} finally {
|
||
await release()
|
||
}
|
||
await expectReservationAccounting()
|
||
}, 30_000)
|
||
// Moves a seeded host onto TARGETS[0] next to another host whose splice there
|
||
// outlives the test, so an over-charge shows instead of clamping at zero.
|
||
async function seedMovedHostBesideNeighbour(index: number): Promise<{
|
||
identity: { userId: string; relayHostId: string }
|
||
neighbour: { userId: string; relayHostId: string }
|
||
}> {
|
||
const { identity } = await seedActiveHostOnIsolatedSource(index)
|
||
await applySelector({ [TARGETS[1]!.id]: 'migration-only' })
|
||
const neighbour = hostIdentity(index + 50)
|
||
expect((await stores[0]!.assign(neighbour, 'us-central1')).cellId).toBe(TARGETS[0]!.id)
|
||
await stores[0]!.acquireActivity(neighbour, {
|
||
activityId: 'splice:neighbour',
|
||
kind: 'splice',
|
||
cellId: TARGETS[0]!.id,
|
||
expiresAt: NOW + 10 * 60 * 60_000
|
||
})
|
||
expect((await stores[1]!.assign(identity, 'us-central1')).cellId).toBe(TARGETS[0]!.id)
|
||
expect(await counters(identity)).toEqual({ controls: 2, splices: 1 })
|
||
await expectReservationAccounting()
|
||
return { identity, neighbour }
|
||
}
|
||
|
||
it('leaves a host with leases on two cells to the lease sweep', async () => {
|
||
await resetFleet()
|
||
const { identity } = await seedMovedHostBesideNeighbour(915)
|
||
// Past every lease but the neighbour's splice.
|
||
const later = new RelayAssignmentStore(databases[0]!, () => NOW + 60 * 60_000, {
|
||
requireLiveCells: true,
|
||
heartbeatTtlMs: 45_000
|
||
})
|
||
try {
|
||
// Aggregate expiry first, with the source's leases still present: the
|
||
// order a lease sweep skipped on a busy row leaves behind.
|
||
await later.releaseExpiredActivity()
|
||
expect(await counters(identity)).toEqual({ controls: 2, splices: 1 })
|
||
await expectReservationAccounting()
|
||
await later.releaseExpiredActivityLeases()
|
||
await later.releaseExpiredActivity()
|
||
expect(await counters(identity)).toEqual({ controls: 0, splices: 0 })
|
||
expect(await reservedRequests(ISOLATED.id)).toBe(0)
|
||
// Only the neighbour's splice is left on the target.
|
||
expect(await reservedRequests(TARGETS[0]!.id)).toBe(2)
|
||
await expectReservationAccounting()
|
||
} finally {
|
||
await applySelector({ [TARGETS[1]!.id]: 'general' })
|
||
}
|
||
}, 30_000)
|
||
|
||
it('frees the source leases on their own cell when the target dies', async () => {
|
||
await resetFleet()
|
||
const { identity } = await seedMovedHostBesideNeighbour(916)
|
||
// The roll finishes, then the target stops heartbeating before the source
|
||
// has released the host. Uncapped, so no fence is needed to move off it.
|
||
await applySelector({ [ISOLATED.id]: 'general' })
|
||
await databases[0]!.query(`UPDATE relay_cell_runtime SET ready = 0 WHERE cell_id = ?`, [
|
||
TARGETS[0]!.id
|
||
])
|
||
await databases[0]!.query(`DELETE FROM relay_cell_connection_limits WHERE cell_id = ?`, [
|
||
TARGETS[0]!.id
|
||
])
|
||
try {
|
||
const moved = await stores[1]!.assign(identity, 'us-central1')
|
||
expect(moved.cellId).toBe(ISOLATED.id)
|
||
expect(await counters(identity)).toEqual({ controls: 1, splices: 0 })
|
||
// The deleted source leases took their units with them; a late release
|
||
// from the source finds nothing and charges nothing.
|
||
expect(await stores[0]!.releaseActivity(identity, `control:${ISOLATED.id}:1`)).toBe(false)
|
||
expect(await reservedRequests(ISOLATED.id)).toBe(1)
|
||
expect(await reservedRequests(TARGETS[0]!.id)).toBe(3)
|
||
await expectReservationAccounting()
|
||
} finally {
|
||
// Re-registers the connection limit, then the heartbeat restores ready.
|
||
await stores[0]!.reconcileCells(CELLS, false)
|
||
await heartbeatAll()
|
||
await applySelector({ [TARGETS[1]!.id]: 'general' })
|
||
}
|
||
}, 30_000)
|
||
// A host main's old placement left behind: its counters were reset while its
|
||
// source lease stayed, and the lease's release floored them at zero.
|
||
async function seedDriftedHost(
|
||
index: number,
|
||
counted: { controls: number; unbackedSourceUnits: number }
|
||
): Promise<{ identity: { userId: string; relayHostId: string }; epoch: number }> {
|
||
const identity = hostIdentity(index)
|
||
await applySelector({ [TARGETS[0]!.id]: 'migration-only', [TARGETS[1]!.id]: 'migration-only' })
|
||
const first = await stores[0]!.assign(identity, 'us-central1')
|
||
expect(first.cellId).toBe(ISOLATED.id)
|
||
await stores[0]!.activateControl(identity, {
|
||
cellId: ISOLATED.id,
|
||
assignmentEpoch: first.assignmentEpoch,
|
||
generation: 1
|
||
})
|
||
await databases[0]!.query(
|
||
`UPDATE relay_assignments SET reserved_controls = ? WHERE user_id = ? AND relay_host_id = ?`,
|
||
[counted.controls, identity.userId, identity.relayHostId]
|
||
)
|
||
// Units the source still counts with no lease behind them.
|
||
await databases[0]!.query(
|
||
`UPDATE relay_cells SET reserved_requests = reserved_requests + ? WHERE cell_id = ?`,
|
||
[counted.unbackedSourceUnits, ISOLATED.id]
|
||
)
|
||
await applySelector({ [TARGETS[0]!.id]: 'general', [TARGETS[1]!.id]: 'general' })
|
||
await applySelector({ [ISOLATED.id]: 'migration-only' }, [ISOLATED.id])
|
||
return { identity, epoch: first.assignmentEpoch }
|
||
}
|
||
|
||
it('heals counters below the leases on the narrowed path', async () => {
|
||
await resetFleet()
|
||
const { identity, epoch } = await seedDriftedHost(918, { controls: 0, unbackedSourceUnits: 0 })
|
||
expect(await counters(identity)).toEqual({ controls: 0, splices: 0 })
|
||
await expectReservationAccounting()
|
||
const release = await holdCellRows([ISOLATED.id])
|
||
try {
|
||
const moved = await Promise.race([
|
||
stores[1]!.assign(identity, 'us-central1'),
|
||
new Promise<'blocked'>((resolve) => setTimeout(() => resolve('blocked'), 3_000))
|
||
])
|
||
expect(moved).toMatchObject({ assignmentEpoch: epoch + 1 })
|
||
expect(TARGETS.map(({ id }) => id)).toContain(moved === 'blocked' ? moved : moved.cellId)
|
||
} finally {
|
||
await release()
|
||
}
|
||
// The kept source control plus the new one.
|
||
expect(await counters(identity)).toEqual({ controls: 2, splices: 0 })
|
||
await expectReservationAccounting()
|
||
expect(await stores[0]!.releaseActivity(identity, `control:${ISOLATED.id}:1`)).toBe(true)
|
||
expect(await counters(identity)).toEqual({ controls: 1, splices: 0 })
|
||
expect(await reservedRequests(ISOLATED.id)).toBe(0)
|
||
await expectReservationAccounting()
|
||
}, 30_000)
|
||
|
||
it('takes the all-rows path for units no lease backs', async () => {
|
||
await resetFleet()
|
||
const { identity, epoch } = await seedDriftedHost(919, { controls: 3, unbackedSourceUnits: 2 })
|
||
await expectReservationAccountingExcept(ISOLATED.id)
|
||
const release = await holdCellRows([ISOLATED.id])
|
||
const held = stores[1]!.assign(identity, 'us-central1').then(
|
||
() => 'placed' as const,
|
||
() => 'failed' as const
|
||
)
|
||
try {
|
||
// It needs the source row to take the unbacked units off it, so while the
|
||
// row is held it either waits or times out; it never places.
|
||
expect(
|
||
await Promise.race([
|
||
held,
|
||
new Promise<'waiting'>((resolve) => setTimeout(() => resolve('waiting'), 1_000))
|
||
])
|
||
).not.toBe('placed')
|
||
} finally {
|
||
await release()
|
||
}
|
||
await held
|
||
// Whether the held dial finished after the release or failed, the next one
|
||
// lands the single move.
|
||
expect(await stores[1]!.assign(identity, 'us-central1')).toMatchObject({
|
||
assignmentEpoch: epoch + 1
|
||
})
|
||
expect(await counters(identity)).toEqual({ controls: 2, splices: 0 })
|
||
await expectReservationAccounting()
|
||
}, 30_000)
|
||
it('refuses at once while the host’s own release holds its assignment row', async () => {
|
||
await resetFleet()
|
||
const { identity, epoch } = await seedDriftedHost(921, { controls: 1, unbackedSourceUnits: 0 })
|
||
const release = await holdRows(
|
||
`SELECT user_id FROM relay_assignments WHERE user_id = ? AND relay_host_id = ?`,
|
||
[identity.userId, identity.relayHostId]
|
||
)
|
||
try {
|
||
const startedAt = performance.now()
|
||
await expect(stores[1]!.assign(identity, 'us-central1')).rejects.toBeInstanceOf(
|
||
RelayAssignmentRowBusyError
|
||
)
|
||
// An isolated pin gets no wait: far under the 1s a release can hold it.
|
||
expect(performance.now() - startedAt).toBeLessThan(500)
|
||
} finally {
|
||
await release()
|
||
}
|
||
expect((await stores[0]!.resolve(identity))?.assignmentEpoch).toBe(epoch)
|
||
expect((await stores[1]!.assign(identity, 'us-central1')).assignmentEpoch).toBe(epoch + 1)
|
||
await expectReservationAccounting()
|
||
}, 30_000)
|
||
|
||
it('waits up to a second for the row of a host pinned to a live general cell', async () => {
|
||
await resetFleet()
|
||
const identity = hostIdentity(922)
|
||
const first = await stores[0]!.assign(identity, 'us-central1')
|
||
const holdAssignment = async (): Promise<() => Promise<void>> =>
|
||
await holdRows(
|
||
`SELECT user_id FROM relay_assignments WHERE user_id = ? AND relay_host_id = ?`,
|
||
[identity.userId, identity.relayHostId]
|
||
)
|
||
|
||
// A short hold, like a calm host's own release: the sticky re-grant waits.
|
||
const shortHold = await holdAssignment()
|
||
const regrant = stores[1]!.assign(identity, 'us-central1')
|
||
try {
|
||
await waitForLock('relay_assignments')
|
||
} finally {
|
||
await shortHold()
|
||
await regrant.catch(() => undefined)
|
||
}
|
||
expect(await regrant).toMatchObject({
|
||
cellId: first.cellId,
|
||
assignmentEpoch: first.assignmentEpoch
|
||
})
|
||
|
||
// Held past the bound: refused, after about the bound and not the pool's.
|
||
const longHold = await holdAssignment()
|
||
try {
|
||
const startedAt = performance.now()
|
||
await expect(stores[1]!.assign(identity, 'us-central1')).rejects.toBeInstanceOf(
|
||
RelayAssignmentRowBusyError
|
||
)
|
||
const waitedMs = performance.now() - startedAt
|
||
expect(waitedMs).toBeGreaterThanOrEqual(900)
|
||
expect(waitedMs).toBeLessThan(2_000)
|
||
} finally {
|
||
await longHold()
|
||
}
|
||
}, 30_000)
|
||
it('never waits on the assignment row while its retry holds the pinned cell row', async () => {
|
||
await resetFleet()
|
||
const identity = hostIdentity(923)
|
||
const first = await stores[0]!.assign(identity, 'us-central1')
|
||
// No control lease, so the sticky re-grant needs the cell row itself.
|
||
expect(
|
||
await stores[0]!.releaseActivity(identity, `control-pending:${first.assignmentEpoch}`)
|
||
).toBe(true)
|
||
// The first attempt finds the cell row busy and retries cell row first.
|
||
const releaseCell = await holdCellRows([first.cellId])
|
||
const regrant = stores[1]!.assign(identity, 'us-central1').then(
|
||
() => 'granted',
|
||
(error: unknown) => error
|
||
)
|
||
let releaseAssignment: (() => Promise<void>) | undefined
|
||
try {
|
||
await waitForLock('relay_cells')
|
||
releaseAssignment = await holdRows(
|
||
`SELECT user_id FROM relay_assignments WHERE user_id = ? AND relay_host_id = ?`,
|
||
[identity.userId, identity.relayHostId]
|
||
)
|
||
await releaseCell()
|
||
const cellFreedAt = performance.now()
|
||
expect(await regrant).toBeInstanceOf(RelayAssignmentRowBusyError)
|
||
// NOWAIT once the cell row is held: nowhere near the 1s bounded wait.
|
||
expect(performance.now() - cellFreedAt).toBeLessThan(300)
|
||
} finally {
|
||
await releaseAssignment?.()
|
||
await releaseCell()
|
||
await regrant
|
||
}
|
||
}, 30_000)
|
||
})
|