Merge remote-tracking branch 'origin/feat/mobile-remote-workspaces' into feat/mobile-remote-files-git-review

This commit is contained in:
Jinwoo-H
2026-10-11 04:54:55 -04:00
7514 changed files with 329443 additions and 253487 deletions
-1
View File
@@ -19,7 +19,6 @@
# .version, which names the immutable remote install dir. A CRLF checkout makes a
# Windows-built client disagree with a mac/Linux-built one on the same release,
# so one host ends up with two relay trees (#17886 review).
/config/relay-assets/** text eol=lf
# Pin the bytes so a patch reads and diffs identically on every host. It is NOT
# what makes the hash right: pnpm hashes a patch LF-normalized, so a CRLF checkout
# cannot change it. Believing otherwise put a hand-computed raw digest in the
@@ -209,6 +209,11 @@ runs:
git -C "$GITHUB_WORKSPACE" diff --exit-code -- package.json pnpm-lock.yaml pnpm-workspace.yaml
fi
# Why --if-present: release cuts run main's workflows against older release tags that predate this script.
- name: Build internal packages
shell: bash
run: pnpm run --if-present build:packages
# pnpm checks the cached record's policy and validity; never bypass verification.
- name: Save pnpm verification record on main
if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' && steps.verification-cache.outputs.key != '' && steps.verification-cache.outputs.cache-hit != 'true'
@@ -0,0 +1,39 @@
name: Prepare the orcad Windows CLI launcher
description: Compiles native/windows-cli-launcher for each requested arch into .build/windows-cli-launcher/<arch>/orca.exe.
inputs:
arches:
description: Space-separated Windows architectures (x64, arm64).
required: true
runs:
using: composite
steps:
- name: Cache the Windows CLI launcher builds
if: runner.os == 'Windows'
uses: actions/cache@v5
with:
path: |
.build/windows-cli-launcher
native/windows-cli-launcher/target
~/.cargo/registry
key: orcad-windows-cli-launcher-${{ runner.arch }}-${{ inputs.arches }}-${{ hashFiles('native/windows-cli-launcher/**', 'config/scripts/build-windows-cli-launcher.mjs', 'resources/build/icon.ico', 'package.json') }}
# Why explicit: a runner image without cargo would otherwise fail inside the orcad build.
- name: Build the launcher for each architecture
if: runner.os == 'Windows'
shell: pwsh
env:
ARCHES: ${{ inputs.arches }}
run: |
if (-not (Get-Command cargo -ErrorAction SilentlyContinue)) {
rustup toolchain install stable --profile minimal
rustup default stable
}
$triples = @{ x64 = 'x86_64-pc-windows-msvc'; arm64 = 'aarch64-pc-windows-msvc' }
foreach ($arch in ($env:ARCHES -split ' ' | Where-Object { $_ })) {
rustup target add $triples[$arch]
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
node config/scripts/build-windows-cli-launcher.mjs --arch $arch --output ".build/windows-cli-launcher/$arch/orca.exe"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
}
-14
View File
@@ -24,7 +24,6 @@ on:
- 'pnpm-workspace.yaml'
- '.npmrc'
- '.pnpmfile.cjs'
- 'config/tsconfig*.json'
- 'config/scripts/ensure-native-runtime.mjs'
- 'config/scripts/rebuild-native-deps.mjs'
- 'config/scripts/node-pty-job-ownership.cjs'
@@ -86,19 +85,6 @@ jobs:
- uses: ./.github/actions/prepare-git-compatibility
- name: Cache TypeScript incremental state
id: typecheck-cache
uses: actions/cache@v5
with:
path: config/*.tsbuildinfo
key: tsbuildinfo-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'config/tsconfig*.json') }}-${{ github.sha }}
restore-keys: |
tsbuildinfo-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'config/tsconfig*.json') }}-
- name: Refresh TypeScript state for this commit
if: steps.typecheck-cache.outputs.cache-hit != 'true'
run: pnpm run typecheck
warm-linux-arm:
runs-on: ubuntu-24.04-arm
timeout-minutes: 10
@@ -544,7 +544,6 @@ jobs:
--capacity-service-account "${CAPACITY_SERVICE_ACCOUNT}" \
--capacity-cell-id "${cell_id}" \
--director-cells-json "${desired_cells_json}" \
--min-instances 0 \
--prune-revisions true \
--release-id "bootstrap-${cell_id}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}")"
echo "${director_result}"
@@ -18,6 +18,17 @@ on:
required: true
default: preserve
type: string
reserve-placement:
description: Preserve step-5 reserve placement, or set it; turning it off is refused while any cell is reserve
required: true
default: preserve
type: choice
options: [preserve, 'off', dry-run, 'on']
shadow-seat-feed-cells:
description: Preserve the step-3 shadow seat-feed cells, or set all or a comma list of cell ids; dropping a reserve cell is refused
required: true
default: preserve
type: string
prune-incompatible-revisions:
description: Retain only the newly verified serving and rollback revisions
required: true
@@ -68,6 +79,8 @@ jobs:
IMAGE_DIGEST: ${{ inputs.image-digest }}
REGIONAL_PLACEMENT_MODE: ${{ inputs.regional-placement-mode }}
REGION_CORRECTION_COHORT_PERCENT: ${{ inputs.region-correction-cohort-percent }}
RESERVE_PLACEMENT: ${{ inputs.reserve-placement }}
SHADOW_SEAT_FEED_CELLS: ${{ inputs.shadow-seat-feed-cells }}
PRUNE_INCOMPATIBLE_REVISIONS: ${{ inputs.prune-incompatible-revisions }}
# Floor the served revision must keep, matching relay_min_instances in
# environments/production.tfvars. This gate only fails a bad deploy; Terraform
@@ -228,7 +241,9 @@ jobs:
--prune-revisions "${PRUNE_INCOMPATIBLE_REVISIONS}" \
--release-id "${RELEASE_ID}" \
--regional-placement-secret-version "${target_version}" \
--region-correction-cohort-percent "${REGION_CORRECTION_COHORT_PERCENT}"
--region-correction-cohort-percent "${REGION_CORRECTION_COHORT_PERCENT}" \
--reserve-placement "${RESERVE_PLACEMENT}" \
--shadow-seat-feed-cells "${SHADOW_SEAT_FEED_CELLS}"
echo "REGIONAL_PLACEMENT_ENABLED=${desired}" >> "${GITHUB_ENV}"
echo "REGIONAL_PLACEMENT_VERSION=${target_version}" >> "${GITHUB_ENV}"
@@ -7,6 +7,46 @@ on:
description: Exact checked-in production Relay sha256 digest to deploy
required: true
type: string
reserve-placement:
description: Preserve step-5 reserve placement, or set it; turning it off is refused while any cell is reserve
required: true
default: preserve
type: choice
options: [preserve, 'off', dry-run, 'on']
expected-rehome-generation:
description: Exact durable regional-rehome generation; it must remain disabled
required: true
type: string
bootstrap-runtime-identity:
description: One-time move from the stamped-cell identity to the director identity
required: true
default: false
type: boolean
predecessor-image-digest:
description: Bootstrap only. Exact immutable serving predecessor digest
required: false
type: string
capacity-cell-id:
description: Register this cell's reviewed staging.tfvars cap and bound with the director
required: true
default: none
type: choice
options: [none, staging-gce-c1, staging-gce-c2, staging-gce-c3, staging-gce-c4]
min-instances:
description: Director floor; preserve keeps the serving revision's, so a reserve run's 2 survives
required: true
default: preserve
type: choice
options: [preserve, '0', '2']
confirmation:
description: BOOTSTRAP_RELAY_DIRECTOR_REHOME_IDENTITY for a bootstrap
required: false
type: string
shadow-seat-feed-cells:
description: Preserve the step-3 shadow seat-feed cells, or set all or a comma list of cell ids; dropping a reserve cell is refused
required: true
default: preserve
type: string
permissions:
contents: read
@@ -34,13 +74,38 @@ jobs:
IMAGE_NAME: relay
EXPECTED_IMAGE_DIGEST: ${{ inputs.expected-image-digest }}
CAPACITY_SERVICE_ACCOUNT: ${{ vars.STAGING_GCP_RELAY_CAPACITY_SERVICE_ACCOUNT }}
# The director must trust the identity every staging Relay workflow authenticates as.
DEPLOY_SERVICE_ACCOUNT: ${{ vars.STAGING_GCP_RELAY_DEPLOY_SERVICE_ACCOUNT }}
ASIA_PROOF_SERVICE_ACCOUNT: ${{ vars.STAGING_GCP_RELAY_ASIA_PROOF_SERVICE_ACCOUNT }}
REGIONAL_PLACEMENT_SECRET: orca-cloud-relay-regional-placement-enabled
DIRECTOR_RUNTIME_SERVICE_ACCOUNT: orca-cloud-staging-relay-dir@onorca-cloud-staging.iam.gserviceaccount.com
PREDECESSOR_RUNTIME_SERVICE_ACCOUNT: orca-cloud-staging-relay@onorca-cloud-staging.iam.gserviceaccount.com
REHOME_AUDIENCE: https://relay-staging.onorca.dev/v1/admin/host-drain
EXPECTED_REHOME_GENERATION: ${{ inputs.expected-rehome-generation }}
BOOTSTRAP_RUNTIME_IDENTITY: ${{ inputs.bootstrap-runtime-identity }}
PREDECESSOR_IMAGE_DIGEST: ${{ inputs.predecessor-image-digest }}
CAPACITY_CELL_ID: ${{ inputs.capacity-cell-id }}
MIN_INSTANCES: ${{ inputs.min-instances }}
steps:
- uses: actions/checkout@v4
- name: Require the expected immutable image
run: '[[ "${EXPECTED_IMAGE_DIGEST}" =~ ^sha256:[a-f0-9]{64}$ ]]'
- name: Require the expected immutable image and identity request
shell: bash
env:
CONFIRMATION: ${{ inputs.confirmation }}
run: |
set -euo pipefail
[[ "${EXPECTED_IMAGE_DIGEST}" =~ ^sha256:[a-f0-9]{64}$ ]]
[[ "${EXPECTED_REHOME_GENERATION}" =~ ^(0|[1-9][0-9]*)$ ]]
[[ "${BOOTSTRAP_RUNTIME_IDENTITY}" =~ ^(true|false)$ ]]
[[ "${MIN_INSTANCES}" =~ ^(preserve|0|2)$ ]]
if test "${BOOTSTRAP_RUNTIME_IDENTITY}" = true; then
[[ "${PREDECESSOR_IMAGE_DIGEST}" =~ ^sha256:[a-f0-9]{64}$ ]]
test "${CAPACITY_CELL_ID}" = none
test "${CONFIRMATION}" = BOOTSTRAP_RELAY_DIRECTOR_REHOME_IDENTITY
else
test -z "${PREDECESSOR_IMAGE_DIGEST}"
fi
- uses: hashicorp/setup-terraform@v3
with:
@@ -68,6 +133,16 @@ jobs:
test "${IMAGE}" = \
"${GCP_REGION}-docker.pkg.dev/${GCP_PROJECT_ID}/${REPOSITORY_ID}/${IMAGE_NAME}@${EXPECTED_IMAGE_DIGEST}"
echo "IMAGE=${IMAGE}" >> "${GITHUB_ENV}"
if test "${CAPACITY_CELL_ID}" != none; then
# The deploy script refuses any topology change beyond this one cell's cap and bound.
DESIRED_CELLS_JSON="$(terraform -chdir=infra/terraform console \
-var-file=environments/staging.tfvars \
<<< 'local.relay_director_cells_json' | jq -er '.')"
jq -e --arg cell "${CAPACITY_CELL_ID}" \
'any(.[]; .id == $cell and .connectionHardCap != null)' \
<<< "${DESIRED_CELLS_JSON}" >/dev/null
echo "DESIRED_CELLS_JSON=${DESIRED_CELLS_JSON}" >> "${GITHUB_ENV}"
fi
- uses: google-github-actions/setup-gcloud@v2
@@ -87,12 +162,37 @@ jobs:
test "${DIGEST}" = "${EXPECTED_IMAGE_DIGEST}"
- name: Deploy director blue/green
env:
# An image change reads every cell's admit mode before the traffic move.
ORCA_RELAY_ADMIN_ID_TOKEN: ${{ steps.google-auth.outputs.id_token }}
RESERVE_PLACEMENT: ${{ inputs.reserve-placement }}
SHADOW_SEAT_FEED_CELLS: ${{ inputs.shadow-seat-feed-cells }}
run: |
regional_version="$(gcloud secrets versions describe latest \
--project "${GCP_PROJECT_ID}" --secret "${REGIONAL_PLACEMENT_SECRET}" \
--format='value(name)' | awk -F/ '{print $NF}')"
[[ "${regional_version}" =~ ^[1-9][0-9]*$ ]]
RELEASE_ID="${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-${GITHUB_SHA:0:8}"
identity_args=()
if test "${BOOTSTRAP_RUNTIME_IDENTITY}" = true; then
identity_args=(
--bootstrap-runtime-identity true
--predecessor-runtime-service-account "${PREDECESSOR_RUNTIME_SERVICE_ACCOUNT}"
--predecessor-image-digest "${PREDECESSOR_IMAGE_DIGEST}"
)
fi
# Omitted, the deploy script inherits the serving revision's floor.
floor_args=()
if test "${MIN_INSTANCES}" != preserve; then
floor_args=(--min-instances "${MIN_INSTANCES}")
fi
topology_args=()
if test "${CAPACITY_CELL_ID}" != none; then
topology_args=(
--capacity-cell-id "${CAPACITY_CELL_ID}"
--director-cells-json "${DESIRED_CELLS_JSON}"
)
fi
node dev/scripts/deploy-relay-blue-green.mjs \
--project "${GCP_PROJECT_ID}" \
--region "${GCP_REGION}" \
@@ -101,9 +201,20 @@ jobs:
--role director \
--max-instances 2 \
--capacity-service-account "${CAPACITY_SERVICE_ACCOUNT}" \
--deploy-service-account "${DEPLOY_SERVICE_ACCOUNT}" \
--asia-proof-service-account "${ASIA_PROOF_SERVICE_ACCOUNT}" \
--regional-placement-secret-version "${regional_version}" \
--min-instances 0 \
"${floor_args[@]}" \
--runtime-service-account "${DIRECTOR_RUNTIME_SERVICE_ACCOUNT}" \
"${identity_args[@]}" \
--rehome-director-service-account "${DIRECTOR_RUNTIME_SERVICE_ACCOUNT}" \
--rehome-audience "${REHOME_AUDIENCE}" \
--rehome-control-origin https://relay-staging.onorca.dev \
--expected-rehome-generation "${EXPECTED_REHOME_GENERATION}" \
"${topology_args[@]}" \
--admin-audience https://relay-staging.onorca.dev/v1/admin/drain \
--reserve-placement "${RESERVE_PLACEMENT}" \
--shadow-seat-feed-cells "${SHADOW_SEAT_FEED_CELLS}" \
--release-id "${RELEASE_ID}"
- name: Smoke director health
@@ -0,0 +1,123 @@
name: Operate Relay Production Cell Flags
# Writes one cell's runtime switch object and waits for the cell to report it applied.
# One cell per run; never fleet-wide. dry-run writes nothing.
on:
workflow_dispatch:
inputs:
mode:
description: dry-run prints the change; write applies it
required: true
default: dry-run
type: choice
options: [dry-run, write]
cell-id:
description: Exact cell id, e.g. production-gce-c26
required: true
type: string
break-glass:
description: >-
Only for a dead or unreachable reserve cell, with set admitMode=default. Typed as
BREAK GLASS <cell-id>; records db without the cell's read-back
required: false
default: ''
type: string
confirm-reserve:
description: Required for admitMode=reserve, typed as RESERVE <cell-id>
required: false
default: ''
type: string
set:
description: >-
Switches to change, e.g. readinessLocal=true,admitMode=reserve (key=default removes a
key). Every other switch keeps its current value. Known: readinessLocal, ticketCheck,
admitMode, intakePerSec, reserveDryRun, rejectionFence, readTimeoutMarginMs,
reregisterInFlight
required: true
type: string
expected-generation:
description: Exact current object generation from a dry run, or 0 if none exists
required: true
type: string
confirmation:
description: Required for write, exactly "WRITE_RELAY_CELL_FLAGS <cell-id>"
required: false
type: string
permissions:
contents: read
id-token: write
# One write at a time across the fleet keeps the audit trail in order.
concurrency:
group: relay-production-cell-flags
cancel-in-progress: false
defaults:
run:
# Explicit bash runs with pipefail, so the script's exit code survives the tee.
shell: bash
working-directory: cloud
jobs:
operate:
if: ${{ vars.ORCA_CLOUD_OPERATIONS_ENABLED == 'true' }}
runs-on: ubuntu-latest
# A flip back waits for the cell to lease every control: 8-50 minutes, scaled to its controls.
timeout-minutes: 60
environment: production
env:
MODE: ${{ inputs.mode }}
CELL_ID: ${{ inputs.cell-id }}
FLAG_CHANGES: ${{ inputs.set }}
EXPECTED_GENERATION: ${{ inputs.expected-generation }}
CONFIRMATION: ${{ inputs.confirmation }}
CONFIRM_RESERVE: ${{ inputs.confirm-reserve }}
BREAK_GLASS: ${{ inputs.break-glass }}
GCP_PROJECT_ID: onorca-cloud
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
- id: google-auth
uses: google-github-actions/auth@v2
with:
workload_identity_provider: ${{ vars.PRODUCTION_GCP_RELAY_DEPLOY_WORKLOAD_IDENTITY_PROVIDER }}
service_account: ${{ vars.PRODUCTION_GCP_RELAY_DEPLOY_SERVICE_ACCOUNT }}
token_format: id_token
id_token_audience: https://relay.onorca.dev/v1/admin/drain
id_token_include_email: true
- uses: google-github-actions/setup-gcloud@v2
- name: Write or preview the cell's switch object
env:
ORCA_RELAY_ADMIN_ID_TOKEN: ${{ steps.google-auth.outputs.id_token }}
run: |
[[ "${CELL_ID}" =~ ^production-gce-c[0-9]{1,3}$ ]]
ORCA_RELAY_CONTROL_ACCESS_TOKEN="$(gcloud auth print-access-token)"
echo "::add-mask::${ORCA_RELAY_CONTROL_ACCESS_TOKEN}"
export ORCA_RELAY_CONTROL_ACCESS_TOKEN
node dev/scripts/operate-relay-cell-flags.mjs \
--mode "${MODE}" --cell-id "${CELL_ID}" \
--cell-origin "https://${CELL_ID#production-gce-}.relay.onorca.dev" \
--project-id "${GCP_PROJECT_ID}" \
--set "${FLAG_CHANGES}" \
--director-origin "https://relay.onorca.dev" --confirm-reserve "${CONFIRM_RESERVE}" \
--break-glass "${BREAK_GLASS}" \
--expected-generation "${EXPECTED_GENERATION}" --confirmation "${CONFIRMATION}" \
| tee "${RUNNER_TEMP}/relay-cell-flags.jsonl"
- name: Audit summary
if: ${{ always() }}
run: |
{
echo "### Relay cell flags: ${MODE} ${CELL_ID}"
echo "Triggered by: ${GITHUB_TRIGGERING_ACTOR}, run ${GITHUB_RUN_ID} attempt ${GITHUB_RUN_ATTEMPT}"
echo '```json'
cat "${RUNNER_TEMP}/relay-cell-flags.jsonl" 2>/dev/null || echo '(no output)'
echo '```'
} >> "${GITHUB_STEP_SUMMARY}"
@@ -0,0 +1,125 @@
name: Operate Relay Staging Cell Flags
# The staging twin of Operate Relay Production Cell Flags: writes one staging cell's runtime
# switch object and waits for the cell to report it applied. One cell per run. dry-run writes
# nothing.
on:
workflow_dispatch:
inputs:
mode:
description: dry-run prints the change; write applies it
required: true
default: dry-run
type: choice
options: [dry-run, write]
cell-id:
description: Exact cell id, e.g. staging-gce-c2
required: true
type: string
break-glass:
description: >-
Only for a dead or unreachable reserve cell, with set admitMode=default. Typed as
BREAK GLASS <cell-id>; records db without the cell's read-back
required: false
default: ''
type: string
confirm-reserve:
description: Required for admitMode=reserve, typed as RESERVE <cell-id>
required: false
default: ''
type: string
set:
description: >-
Switches to change, e.g. readinessLocal=true,admitMode=reserve (key=default removes a
key). Every other switch keeps its current value. Known: readinessLocal, ticketCheck,
admitMode, intakePerSec, reserveDryRun, rejectionFence, readTimeoutMarginMs,
reregisterInFlight
required: true
type: string
expected-generation:
description: Exact current object generation from a dry run, or 0 if none exists
required: true
type: string
confirmation:
description: Required for write, exactly "WRITE_RELAY_CELL_FLAGS <cell-id>"
required: false
type: string
permissions:
contents: read
id-token: write
# Not relay-staging-mutation: a switch write must run while a staging deploy or load run is
# in flight, and it touches neither Cloud SQL nor a revision.
concurrency:
group: relay-staging-cell-flags
cancel-in-progress: false
defaults:
run:
# Explicit bash runs with pipefail, so the script's exit code survives the tee.
shell: bash
working-directory: cloud
jobs:
operate:
if: ${{ vars.ORCA_CLOUD_OPERATIONS_ENABLED == 'true' }}
runs-on: ubuntu-latest
# A flip back waits for the cell to lease every control: 8-50 minutes, scaled to its controls.
timeout-minutes: 60
environment: staging
env:
MODE: ${{ inputs.mode }}
CELL_ID: ${{ inputs.cell-id }}
FLAG_CHANGES: ${{ inputs.set }}
EXPECTED_GENERATION: ${{ inputs.expected-generation }}
CONFIRMATION: ${{ inputs.confirmation }}
CONFIRM_RESERVE: ${{ inputs.confirm-reserve }}
BREAK_GLASS: ${{ inputs.break-glass }}
GCP_PROJECT_ID: onorca-cloud-staging
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
- id: google-auth
uses: google-github-actions/auth@v2
with:
workload_identity_provider: ${{ vars.STAGING_GCP_RELAY_DEPLOY_WORKLOAD_IDENTITY_PROVIDER }}
service_account: ${{ vars.STAGING_GCP_RELAY_DEPLOY_SERVICE_ACCOUNT }}
token_format: id_token
id_token_audience: https://relay-staging.onorca.dev/v1/admin/drain
id_token_include_email: true
- uses: google-github-actions/setup-gcloud@v2
- name: Write or preview the cell's switch object
env:
ORCA_RELAY_ADMIN_ID_TOKEN: ${{ steps.google-auth.outputs.id_token }}
run: |
[[ "${CELL_ID}" =~ ^staging-gce-c[0-9]{1,3}$ ]]
ORCA_RELAY_CONTROL_ACCESS_TOKEN="$(gcloud auth print-access-token)"
echo "::add-mask::${ORCA_RELAY_CONTROL_ACCESS_TOKEN}"
export ORCA_RELAY_CONTROL_ACCESS_TOKEN
node dev/scripts/operate-relay-cell-flags.mjs \
--mode "${MODE}" --cell-id "${CELL_ID}" \
--cell-origin "https://${CELL_ID#staging-gce-}.relay-staging.onorca.dev" \
--project-id "${GCP_PROJECT_ID}" \
--set "${FLAG_CHANGES}" \
--director-origin "https://relay-staging.onorca.dev" --confirm-reserve "${CONFIRM_RESERVE}" \
--break-glass "${BREAK_GLASS}" \
--expected-generation "${EXPECTED_GENERATION}" --confirmation "${CONFIRMATION}" \
| tee "${RUNNER_TEMP}/relay-cell-flags.jsonl"
- name: Audit summary
if: ${{ always() }}
run: |
{
echo "### Relay staging cell flags: ${MODE} ${CELL_ID}"
echo "Triggered by: ${GITHUB_TRIGGERING_ACTOR}, run ${GITHUB_RUN_ID} attempt ${GITHUB_RUN_ATTEMPT}"
echo '```json'
cat "${RUNNER_TEMP}/relay-cell-flags.jsonl" 2>/dev/null || echo '(no output)'
echo '```'
} >> "${GITHUB_STEP_SUMMARY}"
@@ -3,24 +3,60 @@ name: Prove Relay Asia Staging
on:
workflow_dispatch:
inputs:
image-digest:
description: Exact immutable Relay digest deployed on staging C4
mode:
description: prove runs the fixed C4 proof; load runs the generator with free-form arguments
required: true
default: prove
type: choice
options: [prove, load]
image-digest:
description: Prove only. Exact immutable Relay digest deployed on staging C4
required: false
type: string
selector-generation:
description: Exact selector generation with C4 migration-only
required: true
description: Prove only. Exact selector generation with C4 migration-only
required: false
type: string
promote-attempt-id:
description: Durable unique C4 promotion attempt ID
required: true
description: Prove only. Durable unique C4 promotion attempt ID
required: false
type: string
rollback-attempt-id:
description: Durable unique C4 rollback attempt ID
required: true
description: Prove only. Durable unique C4 rollback attempt ID
required: false
type: string
load-region:
description: >-
Load only. asia-east2 runs on the Asia load runner; asia-east2-hosted asks for asia-east2
from a hosted runner; us-central1 runs on a hosted runner
required: false
default: asia-east2
type: choice
options: [asia-east2, asia-east2-hosted, us-central1]
load-shards:
description: Load only. Generator processes, each with its own shard of the 32 load principals
required: false
default: '4'
type: choice
options: ['1', '2', '3', '4']
load-runners:
description: >-
Load only, hosted runners only. Runner machines, each with load-shards generators; one
hosted runner holds about 2,000 connections to one cell
required: false
default: '1'
type: choice
options: ['1', '2']
load-args:
description: >-
Load only. Per-shard generator arguments, e.g. --controls 150 --splices 15
--reconnect-max-seconds 30 --duration-seconds 900. Origins, region, principals and
sharding are set by the workflow
required: false
default: ''
type: string
confirmation:
description: Enter PROVE_ASIA_STAGING
description: Enter PROVE_ASIA_STAGING (prove) or LOAD_RELAY_STAGING (load)
required: true
type: string
@@ -28,8 +64,10 @@ permissions:
contents: read
id-token: write
# A load run drives traffic only, so it must not queue behind (or hold up) the staging deploys
# and switch writes it is there to exercise.
concurrency:
group: relay-staging-mutation
group: ${{ inputs.mode == 'load' && 'relay-staging-load' || 'relay-staging-mutation' }}
cancel-in-progress: false
defaults:
@@ -38,7 +76,7 @@ defaults:
jobs:
prove:
if: ${{ vars.ORCA_CLOUD_OPERATIONS_ENABLED == 'true' && (github.ref == 'refs/heads/main') }}
if: ${{ vars.ORCA_CLOUD_OPERATIONS_ENABLED == 'true' && (github.ref == 'refs/heads/main') && inputs.mode == 'prove' }}
runs-on: [self-hosted, linux, x64, relay-asia-east2-load]
timeout-minutes: 75
environment: staging
@@ -282,7 +320,7 @@ jobs:
retention-days: 7
recover:
if: ${{ always() && github.ref == 'refs/heads/main' }}
if: ${{ always() && github.ref == 'refs/heads/main' && inputs.mode == 'prove' }}
needs: prove
runs-on: blacksmith-2vcpu-ubuntu-2204
timeout-minutes: 15
@@ -331,3 +369,129 @@ jobs:
--attempt-id "${ROLLBACK_ATTEMPT_ID}" \
--image-digest "${IMAGE_DIGEST}")"
test "$(jq -r '.states["staging-gce-c4"]' <<< "${result}")" = migration-only
# The staging auth service mints relay load principals only for this workflow file, so the
# free-form staging load lives here rather than in a workflow of its own.
load:
if: ${{ vars.ORCA_CLOUD_OPERATIONS_ENABLED == 'true' && (github.ref == 'refs/heads/main') && inputs.mode == 'load' }}
runs-on: ${{ inputs.load-region == 'asia-east2' && fromJSON('["self-hosted","linux","x64","relay-asia-east2-load"]') || 'ubuntu-latest' }}
strategy:
# One runner's failure must not drop the other half of the load mid-run.
fail-fast: false
matrix:
runner: ${{ fromJSON(inputs.load-runners == '2' && '[0, 1]' || '[0]') }}
timeout-minutes: 180
environment: staging
env:
DIRECTOR_ORIGIN: https://relay-staging.onorca.dev
AUTH_ORIGIN: https://auth-staging.onorca.dev
LOAD_REGION: ${{ inputs.load-region }}
LOAD_SHARDS: ${{ inputs.load-shards }}
LOAD_RUNNERS: ${{ inputs.load-runners }}
LOAD_RUNNER_INDEX: ${{ matrix.runner }}
LOAD_ARGS: ${{ inputs.load-args }}
steps:
- uses: actions/checkout@v4
- name: Validate the staging load request
shell: bash
env:
CONFIRMATION: ${{ inputs.confirmation }}
run: |
set -euo pipefail
test "${CONFIRMATION}" = LOAD_RELAY_STAGING
[[ "${LOAD_REGION}" =~ ^(asia-east2|asia-east2-hosted|us-central1)$ ]]
[[ "${LOAD_SHARDS}" =~ ^[1-4]$ ]]
[[ "${LOAD_RUNNERS}" =~ ^[12]$ ]]
# The Asia runner is one machine; a second matrix job would only queue behind the first.
if test "${LOAD_RUNNERS}" != 1 && test "${LOAD_REGION}" = asia-east2; then
echo "load-runners 2 needs a hosted runner" >&2
exit 1
fi
read -r -a args <<< "${LOAD_ARGS}"
for arg in "${args[@]}"; do
[[ "${arg}" =~ ^[A-Za-z0-9=:/._,-]+$ ]] || { echo "unsupported characters in load-args" >&2; exit 1; }
case "${arg}" in
--target-origin|--director-origin|--auth-origin|--signing-key-file|--preferred-region|\
--relay-asia-load-principals|--shard-count|--shard-index|--phase-barrier-dir)
echo "${arg} is set by the workflow" >&2
exit 1
;;
esac
done
- id: auth
uses: google-github-actions/auth@v2
with:
workload_identity_provider: ${{ vars.STAGING_GCP_RELAY_ASIA_PROOF_WORKLOAD_IDENTITY_PROVIDER }}
service_account: ${{ vars.STAGING_GCP_RELAY_ASIA_PROOF_SERVICE_ACCOUNT }}
token_format: id_token
id_token_audience: https://relay-staging.onorca.dev/v1/admin/drain
id_token_include_email: true
- uses: actions/setup-node@v4
with:
node-version: 24
- uses: pnpm/action-setup@v4
with:
package_json_file: cloud/package.json
- name: Install exact load-harness dependencies
run: pnpm install --frozen-lockfile
- name: Build the Relay load-harness contract
run: pnpm --filter @orca-cloud/relay-contract build
- name: Run the sharded staging load
env:
# Probes that read director capacity need an admin token; it lasts about an hour.
ORCA_RELAY_ADMIN_ID_TOKEN: ${{ steps.auth.outputs.id_token }}
shell: bash
run: |
set -euo pipefail
load_dir="${RUNNER_TEMP}/relay-staging-load"
mkdir -p "${load_dir}"
fd_limit="$(ulimit -n)"
if test "${fd_limit}" != unlimited; then
[[ "${fd_limit}" =~ ^[0-9]+$ ]]
test "${fd_limit}" -ge 4096
fi
read -r -a args <<< "${LOAD_ARGS}"
pids=()
stop_shards() {
for pid in "${pids[@]}"; do kill "${pid}" 2>/dev/null || true; done
for pid in "${pids[@]}"; do wait "${pid}" 2>/dev/null || true; done
}
trap stop_shards EXIT
for ((shard = 0; shard < LOAD_SHARDS; shard++)); do
node dev/scripts/load-relay-controls.mjs \
--director-origin "${DIRECTOR_ORIGIN}" \
--auth-origin "${AUTH_ORIGIN}" \
--preferred-region "${LOAD_REGION%-hosted}" \
--relay-asia-load-principals 32 \
--shard-count "$((LOAD_SHARDS * LOAD_RUNNERS))" \
--shard-index "$((LOAD_RUNNER_INDEX * LOAD_SHARDS + shard))" \
"${args[@]}" \
> "${load_dir}/shard-${shard}.jsonl" &
pids+=("$!")
done
failed=0
for pid in "${pids[@]}"; do
if ! wait "${pid}"; then failed=1; fi
done
trap - EXIT
for ((shard = 0; shard < LOAD_SHARDS; shard++)); do
jq -c 'select(.event == "relay_load_complete" or .event == "relay_load_progress")' \
"${load_dir}/shard-${shard}.jsonl" | tail -n 1 || true
done
exit "${failed}"
- name: Upload the load reports
if: ${{ always() }}
uses: actions/upload-artifact@v4
with:
name: relay-staging-load-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.runner }}
path: ${{ runner.temp }}/relay-staging-load/
if-no-files-found: warn
retention-days: 7
@@ -417,7 +417,6 @@ jobs:
--capacity-service-account "${CAPACITY_SERVICE_ACCOUNT}" \
--capacity-cell-id "${TARGET_CELL_ID}" \
--director-cells-json "${DESIRED_CELLS_JSON}" \
--min-instances 0 \
--prune-revisions true \
--release-id "${RELEASE_ID}")"
echo "${DEPLOY_RESULT}"
+1
View File
@@ -29,6 +29,7 @@ on:
- 'electron.vite.config.ts'
- 'config/build-plugins/**'
- 'src/main/daemon/**'
- '!src/main/daemon/**/*.test.ts'
- 'src/main/providers/local-pty-provider.ts'
- 'src/main/providers/windows-shell-args.ts'
- 'src/main/providers/windows-shell-preflight-runtime.windows.test.ts'
+48 -7
View File
@@ -70,20 +70,50 @@ env:
DAILY_RETAIN_COUNT: 30
jobs:
# Why: the app and the orcad template it ships must come from one commit, so resolve main once.
resolve-main:
if: github.repository == 'stablyai/orca'
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
head_sha: ${{ steps.resolve.outputs.head_sha }}
steps:
- name: Resolve main
id: resolve
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
head_sha="$(gh api "repos/$GITHUB_REPOSITORY/commits/main" --jq .sha)"
[[ "$head_sha" =~ ^[0-9a-f]{40}$ ]] || { echo "::error::Could not resolve main"; exit 1; }
echo "head_sha=$head_sha" >>"$GITHUB_OUTPUT"
# Why its own job: this package ships relays for Windows SSH hosts, and only a
# Windows runner compiles the addon that launches one outside sshd's job.
# Why main and not the mac job's head_sha: that is resolved inside the mac job.
# A commit or two of drift is harmless; build-relay rejects a stale or wrong-arch addon.
relay-windows-process-tree:
if: github.repository == 'stablyai/orca'
needs: resolve-main
permissions:
contents: read
uses: ./.github/workflows/relay-windows-process-tree.yml
with:
ref: main
ref: ${{ needs.resolve-main.outputs.head_sha }}
# As release-cut does: every SSH host runs orcad, deployed from the template this build ships.
orcad-template:
needs: resolve-main
permissions:
contents: read
uses: ./.github/workflows/node-server-tests.yml
with:
ref: ${{ needs.resolve-main.outputs.head_sha }}
build_template: true
build-daily-mac:
needs: relay-windows-process-tree
needs:
- resolve-main
- relay-windows-process-tree
- orcad-template
if: github.repository == 'stablyai/orca'
outputs:
tag: ${{ steps.release.outputs.tag }}
@@ -102,7 +132,7 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
with:
ref: main
ref: ${{ needs.resolve-main.outputs.head_sha }}
# Version helpers only read HEAD; published versions come from git tags.
fetch-depth: 1
# Why: this job only reads stablyai/orca and never pushes; every write
@@ -249,7 +279,7 @@ jobs:
node config/scripts/daily-build-version.mjs \
>"$RUNNER_TEMP/daily-identity.txt"
grep -E '^(version|build_number)=' "$RUNNER_TEMP/daily-identity.txt"
# Why check rather than trust: the checkout above pins `ref: main`, but a
# Why check rather than trust: the checkout above pins the resolved main commit, but a
# workflow_dispatch runs this file from whatever branch was dispatched. A
# branch that edits this step while main still has the old script yields
# an empty name and an untitled release — silent, and only visible once
@@ -282,6 +312,14 @@ jobs:
# sshd's job for a standard user on a Windows SSH host.
ORCA_REQUIRE_RELAY_NATIVE_ADDONS: x64,arm64
# After the app build so nothing that cleans out/ can drop it; electron-builder ships it.
- name: Download the orcad deployment template
if: steps.freshness.outputs.should_build == 'true'
uses: actions/download-artifact@v8
with:
name: orcad-template
path: out/orcad-template
# Why a second mint: everything from here on writes to the daily repo, and
# the notary round trip inside the publish step can be tens of minutes. The
# token minted at the top has already spent install + build of its one hour
@@ -366,6 +404,8 @@ jobs:
GH_TOKEN: ${{ steps.app_token_publish.outputs.token }}
ORCA_DAILY_BUILD_VERSION: ${{ steps.daily.outputs.version }}
ORCA_BUILD_COMMIT: ${{ steps.daily.outputs.commit }}
# beforePack and afterPack fail the package when the template is absent.
ORCA_REQUIRE_ORCAD_TEMPLATE: '1'
CSC_LINK: ${{ secrets.MAC_CERTS }}
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }}
# Why all three: electron-builder's notarize step authenticates to the
@@ -577,3 +617,4 @@ jobs:
tag: ${{ needs.build-daily-mac.outputs.tag }}
ref: ${{ needs.build-daily-mac.outputs.head_sha }}
version: ${{ needs.build-daily-mac.outputs.version }}
orcad_template: true
+1 -1
View File
@@ -59,7 +59,7 @@ on:
required: true
type: string
orcad_template:
description: Ship the orcad-template artifact the calling run built (adhoc does; hourly and daily do not yet)
description: Ship the orcad-template artifact the calling run built
required: false
type: boolean
default: false
+86 -233
View File
@@ -27,10 +27,6 @@ on:
required: false
type: boolean
default: true
ssh_source_changed:
description: '"true" when the PR touches SSH execution source; gates the Docker-SSH lane'
required: false
type: string
workflow_dispatch:
inputs:
ref:
@@ -285,8 +281,6 @@ jobs:
- name: Install native build and headless UI tools
# Why ripgrep: Quick Open's bounded host-side search requires rg instead of an
# unbounded inventory fallback; the paired fixture exercises that real boundary.
# Why openssh-client: the Docker-SSH fixture shells out to ssh/ssh-keygen, and this
# lane now receives those specs from pr.yml's SSH source mapping.
env: *e2e_tool_packages
run: *install_e2e_tools
@@ -309,46 +303,29 @@ jobs:
else
# Dispatching an older ref retains its existing dedicated-owner exclusions.
jq -r '.[] | select(
. != "tests/e2e/local-ssh-browser-routing.spec.ts" and
. != "tests/e2e/ssh-client-hosted-browser-drop-reconnect.spec.ts" and
. != "tests/e2e/pty-input-write-queue-ssh.spec.ts" and
. != "tests/e2e/ssh-ai-vault-session-history.spec.ts" and
. != "tests/e2e/ssh-codex-display-artifacts-repro.spec.ts" and
. != "tests/e2e/ssh-cold-activation-restore.spec.ts" and
. != "tests/e2e/ssh-cold-hydration-gap-tab-seeding.spec.ts" and
. != "tests/e2e/ssh-emptied-worktree-reactivation.spec.ts" and
. != "tests/e2e/ssh-docker-five-pane-input-under-flood.spec.ts" and
. != "tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts" and
. != "tests/e2e/ssh-docker-half-open-link.spec.ts" and
. != "tests/e2e/ssh-docker-quick-open-large-listing.spec.ts" and
. != "tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts" and
. != "tests/e2e/ssh-docker-relay-stall-credential.spec.ts" and
. != "tests/e2e/ssh-docker-resource-accumulation.spec.ts" and
. != "tests/e2e/ssh-docker-transport-drop-recovery.spec.ts" and
. != "tests/e2e/ssh-external-image-preview.spec.ts" and
. != "tests/e2e/ssh-lost-kill-tab-resurrection.spec.ts" and
. != "tests/e2e/ssh-pi-compatible-agent-title.spec.ts" and
. != "tests/e2e/ssh-port-forward-lifecycle.spec.ts" and
. != "tests/e2e/ssh-reattach-home-partition.spec.ts" and
. != "tests/e2e/ssh-reconnect-tab-destruction.spec.ts" and
. != "tests/e2e/ssh-restart-tab-accumulation.spec.ts" and
. != "tests/e2e/ssh-skill-installation.spec.ts" and
. != "tests/e2e/ssh-stale-resume-execution-host-scope.spec.ts" and
. != "tests/e2e/ssh-startup-local-shadow.spec.ts" and
. != "tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts" and
. != "tests/e2e/terminal-inline-images-ssh.spec.ts" and
. != "tests/e2e/ssh-docker-watcher-isolation.spec.ts" and
. != "tests/e2e/ssh-terminal-parking.spec.ts" and
. != "tests/e2e/terminal-retention-budget.spec.ts" and
. != "tests/e2e/ssh-startup-exec-readiness.spec.ts" and
. != "tests/e2e/paired-startup-exec-readiness.spec.ts" and
. != "tests/e2e/ssh-browser-network-execution-route.docker.unit.test.ts" and
. != "tests/e2e/ssh-localhost.spec.ts" and
. != "tests/e2e/terminal-ibus-hangul-native.spec.ts" and
. != "tests/e2e/orcad-serve-mode-switch.spec.ts" and
. != "tests/e2e/workspace-layout-oracle-headless.spec.ts" and
. != "tests/e2e/ssh-orcad-auto-convert.spec.ts" and
. != "tests/e2e/windows-missing-appdata-startup.spec.ts" and
. != "tests/e2e/ssh-orcad-idle-exit.spec.ts"
. != "tests/e2e/ssh-orcad-idle-exit.spec.ts" and
. != "tests/e2e/ssh-orcad-browser-capabilities.spec.ts" and
. != "tests/e2e/ssh-orcad-browser-service-status.spec.ts" and
. != "tests/e2e/ssh-orcad-browser-routing.spec.ts" and
. != "tests/e2e/ssh-orcad-editor-ownership.spec.ts" and
. != "tests/e2e/ssh-orcad-markdown-conversion.spec.ts" and
. != "tests/e2e/ssh-orcad-markdown-link-refresh.spec.ts" and
. != "tests/e2e/ssh-orcad-markdown-live-documents.spec.ts" and
. != "tests/e2e/ssh-orcad-open-in-owner.spec.ts" and
. != "tests/e2e/ssh-orcad-editor-watch-recovery.spec.ts" and
. != "tests/e2e/ssh-orcad-explorer-watch-recovery.spec.ts" and
. != "tests/e2e/ssh-orcad-explorer-selected-host.spec.ts" and
. != "tests/e2e/ssh-orcad-browser-drop-owner.spec.ts" and
. != "tests/e2e/ssh-orcad-terminal-root-owner.spec.ts" and
. != "tests/e2e/ssh-orcad-terminal-journeys.spec.ts" and
. != "tests/e2e/ssh-orcad-workspace-tools.spec.ts" and
. != "tests/e2e/ssh-orcad-vm-provisioned-root.spec.ts"
)' <<<"$TEST_FILES_JSON" > "$RUNNER_TEMP/general-e2e-specs"
fi
mapfile -t TEST_FILES < "$RUNNER_TEMP/general-e2e-specs"
@@ -357,13 +334,6 @@ jobs:
exit 0
fi
E2E_ENV=(SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 ORCA_E2E_WEB_CLIENT=1 ORCA_RELAY_PATH="$GITHUB_WORKSPACE/out/relay")
# Second clause: a spec that reads ORCA_E2E_SSH_DOCKER test.skip()s itself without it, so
# naming only one trigger silently skipped every other Docker-SSH spec in this lane.
# The first clause stays because that spec needs Docker without referencing the variable.
if printf '%s\n' "${TEST_FILES[@]}" | grep -qx 'tests/e2e/ephemeral-vm-provisioned-root.spec.ts' \
|| grep -l 'ORCA_E2E_SSH_DOCKER' "${TEST_FILES[@]}" >/dev/null 2>&1; then
E2E_ENV+=(ORCA_E2E_SSH_DOCKER=1)
fi
E2E_PROJECT_ARGS=()
if grep -l '@headful' "${TEST_FILES[@]}" >/dev/null; then
E2E_PROJECT_ARGS+=(--project=electron-headful)
@@ -380,123 +350,6 @@ jobs:
retention-days: 7
if-no-files-found: ignore
ssh-docker-watcher-isolation:
name: ssh docker watcher isolation (${{ matrix.shard }}/4)
needs: [build, prepare-native-cache]
# Each excluded changed spec must trigger its dedicated owner.
if: >-
inputs.test_files == '' ||
inputs.ssh_source_changed == 'true' ||
contains(inputs.test_files, 'tests/e2e/local-ssh-browser-routing.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/ssh-client-hosted-browser-drop-reconnect.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/pty-input-write-queue-ssh.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/ssh-ai-vault-session-history.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/ssh-codex-display-artifacts-repro.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/ssh-cold-activation-restore.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/ssh-cold-hydration-gap-tab-seeding.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/ssh-emptied-worktree-reactivation.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/ssh-docker-five-pane-input-under-flood.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/ssh-docker-bulk-open-freeze-repro.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/ssh-docker-half-open-link.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/ssh-docker-quick-open-large-listing.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/ssh-docker-reconnect-pane-restore.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/ssh-docker-relay-stall-credential.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/ssh-docker-resource-accumulation.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/ssh-docker-transport-drop-recovery.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/ssh-external-image-preview.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/ssh-lost-kill-tab-resurrection.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/ssh-pi-compatible-agent-title.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/ssh-port-forward-lifecycle.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/ssh-reconnect-tab-destruction.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/ssh-restart-tab-accumulation.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/ssh-skill-installation.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/ssh-stale-resume-execution-host-scope.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/ssh-terminal-window-wake-stale-grid-repro.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/terminal-inline-images-ssh.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/ssh-docker-watcher-isolation.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/ssh-terminal-parking.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/terminal-retention-budget.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/ssh-startup-exec-readiness.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/paired-startup-exec-readiness.spec.ts')
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
timeout-minutes: 60
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ inputs.ref || github.ref }}
- name: Install native build and headless UI tools
env: *e2e_tool_packages
run: *install_e2e_tools
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: electron
- name: Download E2E build output
uses: actions/download-artifact@v8
with:
name: e2e-build-out
path: out/
# Why: this is the release-path proof that the deployed Linux relay keeps
# its PTY and explorer live across a real watcher SIGSEGV.
- name: Run Docker SSH watcher isolation E2E
if: matrix.shard == 1
run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-watcher-isolation
# Why: Playwright empties test-results/ when it starts, so each step here used to
# destroy the previous step's traces. Only the last lane's failure was ever
# diagnosable from the artifact; set each lane aside before the next one runs.
- name: Keep watcher-isolation traces
if: always() && matrix.shard == 1
run: |
if [ -d test-results ]; then
mkdir -p e2e-traces
mv test-results "e2e-traces/watcher-isolation"
fi
# Keep every SSH journey after a test failure; stop work on superseded commits.
- name: Run Docker SSH terminal parking + startup readiness E2E
if: '!cancelled() && matrix.shard == 1'
run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker-terminal-parking
- name: Keep terminal-parking traces
if: always() && matrix.shard == 1
run: |
if [ -d test-results ]; then
mkdir -p e2e-traces
mv test-results "e2e-traces/terminal-parking"
fi
# Separate VMs isolate destructive SSH fixtures while keeping one worker per shard.
- name: Run remaining Docker SSH E2E
if: '!cancelled()'
run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:ssh-docker --shard=${{ matrix.shard }}/4
- name: Keep remaining-ssh-docker traces
if: always()
run: |
if [ -d test-results ]; then
mkdir -p e2e-traces
mv test-results "e2e-traces/remaining-ssh-docker"
fi
- name: Upload watcher isolation traces
if: failure() || cancelled()
uses: actions/upload-artifact@v7
with:
name: playwright-traces-ssh-docker-watcher-isolation-${{ matrix.shard }}
path: e2e-traces/
retention-days: 7
if-no-files-found: ignore
ssh-browser-network-route:
name: ssh browser network route
if: inputs.test_files == '' || contains(inputs.test_files, 'tests/e2e/ssh-browser-network-execution-route.docker.unit.test.ts')
@@ -520,9 +373,12 @@ jobs:
orcad-serve-mode-switch:
name: orca serve Electron/orcad mode switch (D7)
needs: [build, prepare-native-cache]
if: inputs.test_files == '' || contains(inputs.test_files, 'tests/e2e/orcad-serve-mode-switch.spec.ts')
if: >-
inputs.test_files == '' ||
contains(inputs.test_files, 'tests/e2e/orcad-serve-mode-switch.spec.ts') ||
contains(inputs.test_files, 'tests/e2e/workspace-layout-oracle-headless.spec.ts')
runs-on: ubuntu-latest
timeout-minutes: 30
timeout-minutes: 45
env:
ORCA_BACKGROUND_LAUNCH: '1'
steps:
@@ -549,11 +405,21 @@ jobs:
pnpm build:orcad
node config/scripts/build-orcad-template.mjs --targets "$slot"
- name: Switch serve hosts on one profile
if: inputs.test_files == '' || contains(inputs.test_files, 'tests/e2e/orcad-serve-mode-switch.spec.ts')
env:
SKIP_BUILD: '1'
ORCA_E2E_ORCAD_SERVE: '1'
ORCA_E2E_FORWARD_APP_LOGS: '1'
run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh pnpm exec playwright test --config tests/playwright.config.ts tests/e2e/orcad-serve-mode-switch.spec.ts --project=electron-headless --workers=1
# The layout oracle's headless scenarios run on both serve hosts, so they need this job's orcad.
- name: Layout oracle on headless serve (orcad and Electron)
if: >-
!cancelled() &&
(inputs.test_files == '' || contains(inputs.test_files, 'tests/e2e/workspace-layout-oracle-headless.spec.ts'))
env:
SKIP_BUILD: '1'
ORCA_E2E_FORWARD_APP_LOGS: '1'
run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh pnpm exec playwright test --config tests/playwright.config.ts tests/e2e/workspace-layout-oracle-headless.spec.ts --project=electron-headless --workers=1
- uses: actions/upload-artifact@v7
if: failure()
with:
@@ -568,7 +434,7 @@ jobs:
orcad-auto-convert-docker:
name: ssh host auto-converts to managed orcad (Docker)
needs: [build, prepare-native-cache]
if: inputs.test_files == '' || contains(inputs.test_files, 'tests/e2e/ssh-orcad-auto-convert.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-idle-exit.spec.ts')
if: inputs.test_files == '' || contains(inputs.test_files, 'tests/e2e/ssh-orcad-auto-convert.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-idle-exit.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-browser-capabilities.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-browser-service-status.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-browser-routing.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-editor-ownership.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-markdown-conversion.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-markdown-link-refresh.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-markdown-live-documents.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-open-in-owner.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-editor-watch-recovery.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-explorer-watch-recovery.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-explorer-selected-host.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-browser-drop-owner.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-terminal-root-owner.spec.ts')
runs-on: ubuntu-latest
timeout-minutes: 45
env:
@@ -604,7 +470,7 @@ jobs:
ORCA_RELAY_PATH: ${{ github.workspace }}/out/relay
run: |
export ORCA_E2E_ORCAD_CONVERT_TEMPLATE="$RUNNER_TEMP/orcad-convert-template"
xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh pnpm exec playwright test --config tests/playwright.config.ts tests/e2e/ssh-orcad-auto-convert.spec.ts tests/e2e/ssh-orcad-idle-exit.spec.ts --project=electron-headless --workers=1
xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh pnpm exec playwright test --config tests/playwright.config.ts tests/e2e/ssh-orcad-auto-convert.spec.ts tests/e2e/ssh-orcad-idle-exit.spec.ts tests/e2e/ssh-orcad-browser-capabilities.spec.ts tests/e2e/ssh-orcad-browser-service-status.spec.ts tests/e2e/ssh-orcad-browser-routing.spec.ts tests/e2e/ssh-orcad-editor-ownership.spec.ts tests/e2e/ssh-orcad-markdown-conversion.spec.ts tests/e2e/ssh-orcad-markdown-link-refresh.spec.ts tests/e2e/ssh-orcad-markdown-live-documents.spec.ts tests/e2e/ssh-orcad-open-in-owner.spec.ts tests/e2e/ssh-orcad-editor-watch-recovery.spec.ts tests/e2e/ssh-orcad-explorer-watch-recovery.spec.ts tests/e2e/ssh-orcad-explorer-selected-host.spec.ts tests/e2e/ssh-orcad-browser-drop-owner.spec.ts tests/e2e/ssh-orcad-terminal-root-owner.spec.ts --project=electron-headless --workers=1
- uses: actions/upload-artifact@v7
if: failure()
with:
@@ -613,6 +479,54 @@ jobs:
retention-days: 7
if-no-files-found: ignore
# SSH user journeys (terminal reconnect/restore/faults, Quick Open, ports, links, agent status) on a
# Docker host served by managed orcad. Its own runner so the convert lane keeps its time budget.
orcad-journeys-docker:
name: ssh journeys on managed orcad (Docker)
needs: [build, prepare-native-cache]
if: inputs.test_files == '' || contains(inputs.test_files, 'tests/e2e/ssh-orcad-terminal-journeys.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-workspace-tools.spec.ts') || contains(inputs.test_files, 'tests/e2e/ssh-orcad-vm-provisioned-root.spec.ts')
runs-on: ubuntu-latest
timeout-minutes: 60
env:
ORCA_BACKGROUND_LAUNCH: '1'
steps:
- uses: actions/checkout@v6
with:
ref: ${{ inputs.ref || github.ref }}
persist-credentials: false
- name: Install headless tools
run: sudo apt-get update && sudo apt-get install -y build-essential ripgrep xvfb openbox x11-utils
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: electron
- uses: actions/download-artifact@v8
with:
name: e2e-build-out
path: out/
- name: Build the linux-x64-glibc orcad template
run: |
pnpm build:orcad-prebuilds
pnpm build:orcad-prebuilds --require-slots linux-x64-glibc
pnpm build:orcad
node config/scripts/build-orcad-template.mjs --targets linux-x64-glibc
mv out/orcad-template "$RUNNER_TEMP/orcad-convert-template"
- name: Run SSH journeys on a managed Docker host
env:
SKIP_BUILD: '1'
ORCA_E2E_SSH_DOCKER: '1'
ORCA_E2E_ORCAD_CONVERT_HOST: docker
ORCA_E2E_FORWARD_APP_LOGS: '1'
run: |
export ORCA_E2E_ORCAD_CONVERT_TEMPLATE="$RUNNER_TEMP/orcad-convert-template"
xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh pnpm exec playwright test --config tests/playwright.config.ts tests/e2e/ssh-orcad-terminal-journeys.spec.ts tests/e2e/ssh-orcad-workspace-tools.spec.ts tests/e2e/ssh-orcad-vm-provisioned-root.spec.ts --project=electron-headless --workers=1
- uses: actions/upload-artifact@v7
if: failure()
with:
name: orcad-journeys-docker-traces
path: test-results/
retention-days: 7
if-no-files-found: ignore
# D7 on Windows: both hosts share <userData>\daemon and so one daemon pipe. Built here rather than
# from the Linux e2e build because the slot, template and addons are Windows-native. Also runs the
# missing-AppData startup check, which needs the same Windows e2e build.
@@ -636,6 +550,9 @@ jobs:
with:
resolve-windows-cache: 'true'
restore-windows-cache: 'true'
- uses: ./.github/actions/prepare-windows-cli-launcher
with:
arches: x64
- name: Build the e2e app, CLI, orcad slot and template
shell: bash
run: |
@@ -667,67 +584,3 @@ jobs:
path: test-results/
retention-days: 7
if-no-files-found: ignore
ssh-localhost:
name: localhost SSH terminal and hooks
needs: [build, prepare-native-cache]
if: inputs.test_files == '' || contains(inputs.test_files, 'tests/e2e/ssh-localhost.spec.ts')
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v6
with:
ref: ${{ inputs.ref || github.ref }}
- name: Install SSH server and headless tools
run: sudo apt-get update && sudo apt-get install -y build-essential openssh-client openssh-server python3 ripgrep xvfb zsh openbox x11-utils
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: electron
- uses: actions/download-artifact@v8
with:
name: e2e-build-out
path: out/
- name: Start isolated localhost SSH server
shell: bash
run: |
# Bare shells install Pi extensions only for an existing agent home.
mkdir -p "$HOME/.pi/agent"
fixture="$RUNNER_TEMP/orca-localhost-sshd"
mkdir -p "$fixture"
ssh-keygen -q -t ed25519 -N '' -f "$fixture/host_key"
ssh-keygen -q -t ed25519 -N '' -f "$fixture/client_key"
cat > "$fixture/sshd_config" <<EOF
Port 22222
ListenAddress 127.0.0.1
HostKey $fixture/host_key
PidFile $fixture/sshd.pid
AuthorizedKeysFile $fixture/client_key.pub
StrictModes no
PasswordAuthentication no
KbdInteractiveAuthentication no
UsePAM yes
AllowUsers $(id -un)
Subsystem sftp internal-sftp
EOF
sudo mkdir -p /run/sshd
sudo /usr/sbin/sshd -f "$fixture/sshd_config" -E "$fixture/sshd.log"
ssh -i "$fixture/client_key" -p 22222 -o BatchMode=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null 127.0.0.1 true || { sudo cat "$fixture/sshd.log"; exit 1; }
{
echo "ORCA_E2E_SSH_PORT=22222"
echo "ORCA_E2E_SSH_USER=$(id -un)"
echo "ORCA_E2E_SSH_IDENTITY_FILE=$fixture/client_key"
} >> "$GITHUB_ENV"
- name: Run localhost SSH terminal and hook journey
env:
SKIP_BUILD: '1'
ORCA_E2E_SSH_LOCALHOST: '1'
ORCA_FEATURE_REMOTE_AGENT_HOOKS: '1'
ORCA_E2E_FORWARD_APP_LOGS: '1'
run: xvfb-run --auto-servernum bash .github/scripts/e2e-with-window-manager.sh pnpm exec playwright test --config tests/playwright.config.ts tests/e2e/ssh-localhost.spec.ts --project=electron-headless --workers=1
- uses: actions/upload-artifact@v7
if: failure() || cancelled()
with:
name: localhost-ssh-traces
path: test-results/
retention-days: 7
if-no-files-found: ignore
+22
View File
@@ -127,10 +127,22 @@ jobs:
with:
ref: ${{ needs.preflight.outputs.head_sha }}
# As release-cut does: every SSH host runs orcad, deployed from the template this build ships.
orcad-template:
needs: preflight
if: needs.preflight.outputs.should_build == 'true'
permissions:
contents: read
uses: ./.github/workflows/node-server-tests.yml
with:
ref: ${{ needs.preflight.outputs.head_sha }}
build_template: true
build-hourly-mac:
needs:
- preflight
- relay-windows-process-tree
- orcad-template
if: needs.preflight.outputs.should_build == 'true'
outputs:
tag: ${{ steps.release.outputs.tag }}
@@ -287,6 +299,13 @@ jobs:
# sshd's job for a standard user on a Windows SSH host.
ORCA_REQUIRE_RELAY_NATIVE_ADDONS: x64,arm64
# After the app build so nothing that cleans out/ can drop it; electron-builder ships it.
- name: Download the orcad deployment template
uses: actions/download-artifact@v8
with:
name: orcad-template
path: out/orcad-template
# Why a second mint: everything from here on writes to the hourly repo, and
# the notary round trip inside the publish step can be tens of minutes. The
# token minted at the top has already spent install + build of its one hour
@@ -360,6 +379,8 @@ jobs:
GH_TOKEN: ${{ steps.app_token_publish.outputs.token }}
ORCA_HOURLY_BUILD_VERSION: ${{ steps.hourly.outputs.version }}
ORCA_BUILD_COMMIT: ${{ steps.hourly.outputs.commit }}
# beforePack and afterPack fail the package when the template is absent.
ORCA_REQUIRE_ORCAD_TEMPLATE: '1'
CSC_LINK: ${{ secrets.MAC_CERTS }}
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTS_PASSWORD }}
# Why all three: electron-builder's notarize step authenticates to the
@@ -515,3 +536,4 @@ jobs:
tag: ${{ needs.build-hourly-mac.outputs.tag }}
ref: ${{ needs.build-hourly-mac.outputs.head_sha }}
version: ${{ needs.build-hourly-mac.outputs.version }}
orcad_template: true
+1 -1
View File
@@ -10,7 +10,7 @@ on:
- 'src/main/startup/main-process-quit*'
- 'src/main/window/main-window-state-lifecycle*'
- 'src/main/window/dashboard-popout-window*'
- 'src/shared/child-process/**'
- 'src/packages/process-host/**'
- 'src/shared/update-status-types.ts'
- 'pnpm-lock.yaml'
workflow_dispatch:
+3
View File
@@ -12,6 +12,8 @@ on:
# recordings and the host params check, so any shared edit can move a golden or break mobile's
# typecheck. Why the root lockfile: those modules resolve their packages from the root install.
- 'src/shared/**'
# Mobile's Node RPC recording tools import this package.
- 'src/packages/process-host/**'
- 'pnpm-lock.yaml'
# Mobile launch contracts exercise the real host dispatcher and durable receipt store.
- 'src/main/agent-launch/**'
@@ -40,6 +42,7 @@ on:
paths:
- 'mobile/**'
- 'src/shared/**'
- 'src/packages/process-host/**'
- 'pnpm-lock.yaml'
- 'src/main/agent-launch/**'
- 'src/main/runtime/rpc/**'
+27 -6
View File
@@ -100,16 +100,18 @@ jobs:
if [ "$EVENT_NAME" = push ]; then
# Compare the entire push, including multi-commit pushes and removed files.
if git fetch --no-tags --depth=1 origin "$PUSH_BASE" &&
git diff --name-only --no-renames -z "$PUSH_BASE" HEAD > "$RUNNER_TEMP/node-server-changes"; then
node config/scripts/node-server-change-scope.mjs "$RUNNER_TEMP/node-server-changes" --defer-graph --full-qualification
git diff --name-only --no-renames -z "$PUSH_BASE" HEAD > "$RUNNER_TEMP/node-server-changes" &&
git diff --name-only --no-renames --diff-filter=M -z "$PUSH_BASE" HEAD > "$RUNNER_TEMP/node-server-modified"; then
node config/scripts/node-server-change-scope.mjs "$RUNNER_TEMP/node-server-changes" --defer-graph --full-qualification --modified-paths "$RUNNER_TEMP/node-server-modified"
else
echo 'should_run=true' >> "$GITHUB_OUTPUT"
fi
exit 0
fi
# Compare the tested merge with its base, retaining both sides of renames.
if git diff --name-only --no-renames -z HEAD^1 HEAD > "$RUNNER_TEMP/node-server-changes"; then
node config/scripts/node-server-change-scope.mjs "$RUNNER_TEMP/node-server-changes" --defer-graph
if git diff --name-only --no-renames -z HEAD^1 HEAD > "$RUNNER_TEMP/node-server-changes" &&
git diff --name-only --no-renames --diff-filter=M -z HEAD^1 HEAD > "$RUNNER_TEMP/node-server-modified"; then
node config/scripts/node-server-change-scope.mjs "$RUNNER_TEMP/node-server-changes" --defer-graph --modified-paths "$RUNNER_TEMP/node-server-modified"
else
echo 'should_run=true' >> "$GITHUB_OUTPUT"
fi
@@ -128,9 +130,9 @@ jobs:
EVENT_NAME: ${{ github.event_name }}
run: |
if [ "$EVENT_NAME" = push ]; then
node config/scripts/node-server-change-scope.mjs "$RUNNER_TEMP/node-server-changes" --full-qualification
node config/scripts/node-server-change-scope.mjs "$RUNNER_TEMP/node-server-changes" --full-qualification --modified-paths "$RUNNER_TEMP/node-server-modified"
else
node config/scripts/node-server-change-scope.mjs "$RUNNER_TEMP/node-server-changes"
node config/scripts/node-server-change-scope.mjs "$RUNNER_TEMP/node-server-changes" --modified-paths "$RUNNER_TEMP/node-server-modified"
fi
persistence:
@@ -192,6 +194,11 @@ jobs:
(github.event_name == 'pull_request' ||
(github.ref == 'refs/heads/main' && contains(fromJSON('["push","schedule","workflow_dispatch"]'), github.event_name))) }}
restore-windows-cache: ${{ github.event_name == 'pull_request' || github.event_name == 'push' }}
# The template lane cross-compiles arm64 too, as it does for the process-table addon.
- uses: ./.github/actions/prepare-windows-cli-launcher
if: runner.os == 'Windows'
with:
arches: ${{ (inputs.build_template && matrix.os == 'windows-2022') && 'x64 arm64' || (runner.arch == 'ARM64' && 'arm64' || 'x64') }}
- run: pnpm build:orcad
- wait: bun-orcad
- run: pnpm test:node-server --artifact ${{ runner.os == 'Linux' && '--cross-runtime' || '' }}
@@ -216,6 +223,15 @@ jobs:
if-no-files-found: error
retention-days: 7
overwrite: true
- name: Keep the Windows CLI launchers for the desktop template
if: inputs.build_template && matrix.os == 'windows-2022'
uses: actions/upload-artifact@v7
with:
name: orcad-windows-cli-launcher
path: .build/windows-cli-launcher/
if-no-files-found: error
retention-days: 7
overwrite: true
- uses: actions/setup-node@v6
if: runner.arch == 'X64'
with:
@@ -458,6 +474,11 @@ jobs:
with:
name: orcad-windows-process-tree
path: .build/windows-process-tree
- name: Collect the Windows CLI launchers
uses: actions/download-artifact@v8
with:
name: orcad-windows-cli-launcher
path: .build/windows-cli-launcher
- name: Merge the lanes and gate the full slot matrix
shell: bash
run: |
+8 -1
View File
@@ -16,11 +16,18 @@ on:
# without these a rename lands green and only breaks the next nightly.
- 'src/main/sqlite/sync-database.test.ts'
- 'src/main/runtime/orchestration/db/row-column-lists.test.ts'
- 'src/relay/fs-path-metadata-symlink-concurrency.test.ts'
- 'src/shared/native-chat-tool-attribution-allocation.test.ts'
- 'src/shared/native-chat-tool-pairing.performance.test.ts'
- 'src/main/codex-usage/codex-rollout-file-lifetime.test.ts'
- 'src/main/codex-usage/scanner-incremental-append.test.ts'
- 'src/main/usage/jsonl-file-reader.test.ts'
- 'src/main/usage/jsonl-file-snapshot.test.ts'
- 'src/renderer/src/components/editor/rich-markdown-list-tokenizers.test.ts'
- 'src/renderer/src/components/editor/rich-markdown-lowlight-cache.test.ts'
- 'src/renderer/src/components/terminal-pane/agent-completion-coordinator-queued-inspection-disposal.test.ts'
- 'src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler-queue-retention.test.ts'
- 'src/main/claude-usage/scanner-incremental-append.test.ts'
- 'src/main/claude-usage/store-checkpoint-persistence.test.ts'
permissions:
contents: read
+1
View File
@@ -3,6 +3,7 @@ on:
pull_request:
paths:
- 'src/main/pi/**'
- '!src/main/pi/**/*.test.ts'
- 'tests/tools/pi-owner-runtime-smoke.mjs'
- 'tests/tools/omp-completion-runtime-smoke.mjs'
- '.github/workflows/pi-owner-runtime.yml'
+54 -90
View File
@@ -53,7 +53,6 @@ jobs:
test_files: ${{ steps.e2e_filter.outputs.test_files }}
e2e_run_changed: ${{ steps.e2e_filter.outputs.e2e_run_changed }}
e2e_needs_build: ${{ steps.e2e_filter.outputs.e2e_needs_build }}
ssh_source_changed: ${{ steps.e2e_filter.outputs.ssh_source_changed }}
native_ime_source_changed: ${{ steps.e2e_filter.outputs.native_ime_source_changed }}
wsl_source_changed: ${{ steps.e2e_filter.outputs.wsl_source_changed }}
steps:
@@ -131,12 +130,7 @@ jobs:
# authorities, exclusions, and sentinels without evaluating workflow shell.
TEST_FILES_JSON="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-e2e-source-routing.mjs)"
echo "test_files=$TEST_FILES_JSON" >> "$GITHUB_OUTPUT"
# Why a separate signal: the Docker-SSH lane must trigger on SSH source, not on a
# spec name surviving in a route's list. Same routes, so the two cannot drift.
SSH_SOURCE_CHANGED="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-e2e-source-routing.mjs --ssh-source)"
echo "ssh_source_changed=$SSH_SOURCE_CHANGED" >> "$GITHUB_OUTPUT"
echo "SSH source changed: $SSH_SOURCE_CHANGED"
printf '%s\n' "$TEST_FILES_JSON" | E2E_SSH_SOURCE_CHANGED="$SSH_SOURCE_CHANGED" node config/scripts/ci-e2e-job-selection.mjs --job-outputs >> "$GITHUB_OUTPUT"
printf '%s\n' "$TEST_FILES_JSON" | node config/scripts/ci-e2e-job-selection.mjs --job-outputs >> "$GITHUB_OUTPUT"
# Why its own signal: the real-IME lane is a whole ibus session, not a spec, so it must
# trigger on IME source rather than on a spec name in some route's list.
NATIVE_IME_SOURCE_CHANGED="$(printf '%s\n' "$CHANGED" | node config/scripts/pr-e2e-source-routing.mjs --native-ime-source)"
@@ -197,11 +191,22 @@ jobs:
pnpm-lock.yaml
mobile/pnpm-lock.yaml
- name: Set up Bun for localization checks
if: needs.code_paths.outputs.static_analysis == 'true'
# Bun 1.4.3 introduces the compiler used by this job.
- name: Set up Bun for preflight checks
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version-file: config/.bun-version
bun-version: 1.4.3
- name: Check process-host imports
if: '!cancelled()'
id: process-host-imports
background: true
env:
PREFLIGHT_PHASE_SELECTED: ${{ needs.code_paths.outputs.static_analysis == 'true' }}
PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }}
run: |
if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi
bun --bun run check:process-host-imports
# Keep each check in its own log while sharing this runner.
- name: Lint
@@ -217,7 +222,9 @@ jobs:
- name: Reject low-evidence patterns
if: needs.code_paths.outputs.static_analysis == 'true'
run: pnpm run audit:anti-slop
run: |
node config/scripts/sync-anti-slop-plugin.mjs
bun --smol node_modules/oxlint/bin/oxlint --config config/oxlint-anti-slop.json src config tests mobile --deny-warnings
- wait: root-lint
@@ -262,6 +269,18 @@ jobs:
- wait: changed-code-quality
# The compiler overlaps the remaining checks after the type-aware passes release memory.
- name: Typecheck
if: '!cancelled()'
id: typecheck
background: true
env:
PREFLIGHT_PHASE_SELECTED: ${{ needs.code_paths.outputs.typecheck == 'true' }}
PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }}
run: |
if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi
pnpm run typecheck:ci
- name: Check Zustand selector fan-out budget
if: needs.code_paths.outputs.static_analysis == 'true'
run: pnpm run check:zustand-selector-fanout
@@ -308,6 +327,10 @@ jobs:
if: needs.code_paths.outputs.static_analysis == 'true'
run: pnpm run check:runtime-electron-ratchet
- name: Enforce owner-routing ratchet
if: needs.code_paths.outputs.static_analysis == 'true'
run: pnpm run check:owner-routing-ratchet
- name: Check Node runtime pin
if: needs.code_paths.outputs.static_analysis == 'true'
run: pnpm run check:node-runtime-pin
@@ -410,19 +433,6 @@ jobs:
if: needs.code_paths.outputs.static_analysis == 'true'
run: pnpm verify:macos-entitlements
# Why: every project is `composite`, so tsc already writes a .tsbuildinfo that lets
# the next run skip unchanged files. Share one cache entry across commits while the
# PR base stays stable; actions/cache keeps the first successful graph and the
# compiler still invalidates stale files from its content hashes.
- name: Cache TypeScript incremental state
if: needs.code_paths.outputs.typecheck == 'true'
uses: actions/cache@v5
with:
path: config/*.tsbuildinfo
key: tsbuildinfo-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'config/tsconfig*.json') }}-${{ github.event.pull_request.base.sha }}
restore-keys: |
tsbuildinfo-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml', 'config/tsconfig*.json') }}-
# Planning shares setup and stays off the compiler's critical path.
- name: Plan unit selection
if: '!cancelled()'
@@ -432,14 +442,15 @@ jobs:
PREFLIGHT_PHASE_SELECTED: ${{ needs.code_paths.outputs.typecheck == 'true' }}
PREFLIGHT_PRIOR_SUCCESS: ${{ job.status == 'success' }}
ORCA_UNIT_SELECTION_MODE: ${{ vars.ORCA_UNIT_SELECTION_MODE || 'shadow' }}
# Set the repository variable to '5' to roll back full PR shards.
ORCA_UNIT_FULL_SHARD_COUNT: ${{ vars.ORCA_UNIT_FULL_SHARD_COUNT || '10' }}
# Five shards use fewer runner minutes; the repository variable can still request ten.
ORCA_UNIT_FULL_SHARD_COUNT: ${{ vars.ORCA_UNIT_FULL_SHARD_COUNT || '5' }}
run: |
if [ "$PREFLIGHT_PHASE_SELECTED" != true ] || [ "$PREFLIGHT_PRIOR_SUCCESS" != true ]; then exit 0; fi
node config/scripts/ci-unit-plan.mjs
- run: pnpm run typecheck
if: needs.code_paths.outputs.typecheck == 'true'
- wait: typecheck
- wait: process-host-imports
- wait: unit-plan
@@ -492,8 +503,7 @@ jobs:
pnpm exec vitest run --config config/vitest.config.ts \
src/shared/git-binary-compatibility.test.ts \
src/main/git/worktree-safety-real-git.test.ts \
src/main/git/worktree-rebase-update-refs-real-git.test.ts \
src/relay/git-review-draft-binary-compatibility.test.ts
src/main/git/worktree-rebase-update-refs-real-git.test.ts
) &
pids+=("$!")
@@ -505,8 +515,7 @@ jobs:
pnpm exec vitest run --config config/vitest.config.ts \
src/shared/git-binary-compatibility.test.ts \
src/main/git/worktree-safety-real-git.test.ts \
src/main/git/worktree-rebase-update-refs-real-git.test.ts \
src/relay/git-review-draft-binary-compatibility.test.ts
src/main/git/worktree-rebase-update-refs-real-git.test.ts
) &
pids+=("$!")
done
@@ -781,7 +790,6 @@ jobs:
src/main/terminal-history-fish-session.node-pty.test.ts \
src/main/zsh-scoped-histfile.live-shell.test.ts \
src/main/zsh-startup-hook-user-config-equivalence.live-shell.test.ts \
src/main/zsh-wrapper-version-mismatch.live-shell.test.ts \
src/main/runtime/structured-session-cli-login-shell.live-shell.test.ts \
src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \
src/shared/fish-query-reply-child-stdin.node-pty.test.ts \
@@ -795,6 +803,7 @@ jobs:
# Cancellation and failed prerequisites stop the expensive matrix.
if: >-
!cancelled() &&
needs.code_paths.result == 'success' &&
needs.code_paths.outputs.test == 'true' &&
needs.preflight.result == 'success'
uses: ./.github/workflows/unit-tests.yml
@@ -802,6 +811,7 @@ jobs:
node_versions: '["24"]'
runner: ubuntu-24.04-arm
shards: ${{ needs.preflight.outputs.shards }}
browser_contract: ${{ needs.code_paths.outputs.orcad_browser == 'true' }}
# Why a sibling and not part of the test workflow: it is advisory, so it must not delay the
# gate. Inside unit-tests.yml a caller's `needs: test` waited for it, holding verify ~36s
@@ -811,48 +821,6 @@ jobs:
if: ${{ !cancelled() && (needs.test.result == 'success' || needs.test.result == 'failure') }}
uses: ./.github/workflows/unit-selection-evidence.yml
# Why a separate job: the test needs a real Chrome, and the sharded `test` matrix
# would pay for it on every shard to run one file in whichever shard it landed in.
orcad_browser:
name: orcad browser provider
needs: [code_paths, preflight]
if: >-
!cancelled() &&
needs.code_paths.result == 'success' &&
needs.code_paths.outputs.orcad_browser == 'true' &&
needs.preflight.result == 'success'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
# Why no native-runtime: the provider drives the prebuilt agent-browser binary
# shipped in node_modules and never touches node-pty.
- uses: ./.github/actions/install-node-dependencies
# Why the runner's Google Chrome and not its chromium: Ubuntu 24.04 only ships an
# AppArmor userns profile for the Chrome .deb, so chromium dies with "No usable
# sandbox" and the provider passes no --no-sandbox. Why fail instead of skip: an
# unset ORCA_BROWSER_EXECUTABLE is exactly how this test went uncovered for so long.
- name: Resolve Chrome for the browser provider
run: |
set -euo pipefail
chrome="$(command -v google-chrome || command -v google-chrome-stable || true)"
if [ -z "$chrome" ]; then
echo "::error::No Google Chrome on the runner; the browser provider test would silently skip."
exit 1
fi
"$chrome" --version
echo "ORCA_BROWSER_EXECUTABLE=$chrome" >> "$GITHUB_ENV"
- name: Test external Chromium browser provider
run: |
pnpm exec vitest run --config config/vitest.config.ts \
src/main/orcad/external-chromium-browser-process.integration.test.ts
# Why its own job: it needs mobile/node_modules and a real browser, and the sharded `test`
# matrix would pay for both on every shard to run two files. It builds the same bundle the
# package job ships, and adds the render and census checks packaging does not run.
@@ -998,16 +966,14 @@ jobs:
- uses: ./.github/actions/install-node-dependencies
- name: Build relay companions
run: pnpm run build:relay
- name: Setup Node 18 runtime
uses: actions/setup-node@v6
with:
node-version: '18'
- name: Smoke managed-hook companions
run: node config/scripts/smoke-managed-hook-runtime-node18.mjs
# The process-host package declares engines >=18; load its staged public output on Node 18.
- name: Smoke process-host package
run: node config/scripts/smoke-process-host-node18.mjs
package:
name: package
@@ -1232,12 +1198,14 @@ jobs:
- name: Test Windows-specific boundaries
run: >-
pnpm exec vitest run --config config/vitest.config.ts
src/main/zcode/zcode-plan-api-key-store-native-windows.test.ts
config/scripts/rebuild-native-deps.test.mjs
config/scripts/rebuild-native-deps-windows-process-tree.test.mjs
config/scripts/rebuild-native-deps-node-pty.test.mjs
config/scripts/ensure-native-runtime-job-ownership.test.mjs
config/scripts/verify-packaged-node-pty-job-ownership.test.mjs
config/scripts/windows-pe-machine.test.mjs
config/scripts/build-windows-cli-launcher.test.mjs
config/scripts/script-module-dependencies.test.mjs
src/main/windows-registry-addon.test.ts
config/scripts/windows-process-tree-gyp-path.test.mjs
@@ -1252,12 +1220,15 @@ jobs:
src/main/persistence/profile-state/profile-state-writer-stall.electron.test.ts
src/main/providers/windows-conpty-wide-char-duplication.node-pty.test.ts
src/main/providers/pty-repaint-wide-char-buffer.node-pty.test.ts
src/shared/child-process/windows-command-line.win32.test.ts
src/shared/child-process/windows-cmd-shim-resolution.test.ts
src/shared/child-process/windows-cmd-shim-resolution.win32.test.ts
src/shared/__tests__/process-host/windows-command-line.win32.test.ts
src/shared/__tests__/process-host/windows-cmd-shim-resolution.test.ts
src/shared/__tests__/process-host/windows-cmd-shim-resolution.win32.test.ts
src/main/agent-hooks/windows-hook-payload-delivery.test.ts
src/main/jcode/hook-gate-script.test.ts
src/main/agent-hooks/windows-direct-cmd-hook-command.test.ts
src/main/agent-hooks/windows-cmd-hook-command-unicode.test.ts
src/main/agent-hooks/windows-batch-hook-launcher.test.ts
src/main/agent-hooks/windows-powershell-hook-launcher.test.ts
src/main/codex/windows-hook-command.test.ts
src/main/codex/windows-hook-upgrade.test.ts
src/main/codex/hook-service-managed-install.test.ts
@@ -1290,8 +1261,6 @@ jobs:
src/main/ipc/pty-spawn-env-codex-resume-provenance.test.ts
src/main/ipc/preflight-provider-command-selection.test.ts
src/main/ipc/preflight-runnable-local-cli.test.ts
src/relay/windows-port-scan.win32.test.ts
src/main/ssh/ssh-relay-upload-stage-windows-identity.test.ts
src/main/ssh/remote-node-runtime-store-windows.test.ts
# Why the :parallel variant: identical to build:release except the three
@@ -1365,7 +1334,6 @@ jobs:
# workflow is queued. The head SHA is immutable and works for every PR.
ref: ${{ github.event.pull_request.head.sha }}
test_files: ${{ needs.code_paths.outputs.test_files }}
ssh_source_changed: ${{ needs.code_paths.outputs.ssh_source_changed }}
run_changed_e2e: ${{ needs.code_paths.outputs.e2e_run_changed != 'false' }}
needs_build: ${{ needs.code_paths.outputs.e2e_needs_build != 'false' }}
@@ -1404,7 +1372,6 @@ jobs:
- xterm_patch_sync
- shell_contracts
- test
- orcad_browser
- mobile_web_app
- cross-version-wire
- managed_hook_node18
@@ -1439,8 +1406,6 @@ jobs:
SHELL_CONTRACTS_SHOULD_RUN: ${{ needs.code_paths.outputs.shell_contracts }}
TEST: ${{ needs.test.result }}
TEST_SHOULD_RUN: ${{ needs.code_paths.outputs.test }}
ORCAD_BROWSER: ${{ needs.orcad_browser.result }}
ORCAD_BROWSER_SHOULD_RUN: ${{ needs.code_paths.outputs.orcad_browser }}
MOBILE_WEB_APP: ${{ needs.mobile_web_app.result }}
MOBILE_WEB_APP_SHOULD_RUN: ${{ needs.code_paths.outputs.mobile_web_app }}
CROSS_VERSION_WIRE: ${{ needs.cross-version-wire.result }}
@@ -1480,7 +1445,6 @@ jobs:
check_job xterm_patch_sync "$XTERM_PATCH_SYNC" "$XTERM_PATCH_SYNC_SHOULD_RUN"
check_job shell_contracts "$SHELL_CONTRACTS" "$SHELL_CONTRACTS_SHOULD_RUN"
check_job test "$TEST" "$TEST_SHOULD_RUN"
check_job orcad_browser "$ORCAD_BROWSER" "$ORCAD_BROWSER_SHOULD_RUN"
check_job mobile_web_app "$MOBILE_WEB_APP" "$MOBILE_WEB_APP_SHOULD_RUN"
check_job cross-version-wire "$CROSS_VERSION_WIRE" "$CROSS_VERSION_WIRE_SHOULD_RUN"
check_job managed_hook_node18 "$MANAGED_HOOK_NODE18" "$MANAGED_HOOK_NODE18_SHOULD_RUN"
+60
View File
@@ -0,0 +1,60 @@
name: Preflight comparison
on:
pull_request:
paths:
- .github/workflows/preflight-benchmark.yml
- config/scripts/benchmark-preflight-*.mjs
- config/scripts/preflight-benchmark-command.mjs
workflow_dispatch:
permissions:
contents: read
concurrency:
group: preflight-comparison-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
ORCA_BACKGROUND_LAUNCH: '1'
jobs:
compilers:
runs-on: ubuntu-24.04-arm
timeout-minutes: 25
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: none
node-version: '24'
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: 1.4.3
- name: Compare native and Bun typechecking
run: node config/scripts/benchmark-preflight-compilers.mjs
- uses: actions/upload-artifact@v7
if: always()
with:
name: preflight-compilers
path: .build/preflight-benchmark/
retention-days: 30
static-analysis:
runs-on: ubuntu-24.04-arm
timeout-minutes: 25
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: none
node-version: '24'
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: 1.4.3
- name: Compare anti-slop patch and script runtimes
run: node config/scripts/benchmark-preflight-static-analysis.mjs
- uses: actions/upload-artifact@v7
if: always()
with:
name: preflight-static-analysis
path: .build/preflight-benchmark/
retention-days: 30
@@ -0,0 +1,58 @@
name: Preflight CLI platforms
on:
pull_request:
paths:
- 'src/main/ipc/preflight*'
- 'src/main/ipc/agent-detection-shell-path.ts'
- 'src/main/ipc/command-path-resolver*'
- 'src/main/preflight/**'
- 'src/main/startup/*shell*'
- 'src/packages/process-host/**'
- '.github/actions/install-node-dependencies/**'
- '.github/workflows/preflight-cli-platforms.yml'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: preflight-cli-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
preflight:
name: Preflight CLI (${{ matrix.os }})
runs-on: ${{ matrix.os }}
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
os: [ubuntu-22.04, macos-15, windows-2022]
env:
ORCA_BACKGROUND_LAUNCH: '1'
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: node
cache-electron-package: 'true'
- name: Check native CLI execution and shell PATH discovery
run: >-
pnpm exec vitest run --config config/vitest.config.ts --maxWorkers=2
src/main/ipc/preflight-command-exec.test.ts
src/main/ipc/preflight-host-cli-status.test.ts
src/main/ipc/preflight-runnable-local-cli.test.ts
src/main/ipc/preflight-agent-detection.test.ts
src/main/ipc/preflight-agent-refresh.test.ts
src/main/ipc/preflight-wsl-agent-detection.test.ts
src/main/ipc/preflight-windows-path-refresh.repro.test.ts
src/main/startup/hydrate-shell-path.test.ts
src/main/startup/hydrate-shell-path.windows.test.ts
src/main/startup/login-shell-environment.test.ts
src/main/runtime/rpc/methods/preflight.test.ts
src/packages/process-host/src/process-tree-termination.test.ts
src/packages/process-host/src/run-process.test.ts
src/packages/process-host/src/run-process-termination-failure.test.ts
-6
View File
@@ -1520,12 +1520,6 @@ jobs:
node config/scripts/runtime-file-watcher-fault-harness.mjs
ELECTRON_RUN_AS_NODE=1 pnpm exec electron config/scripts/runtime-file-watcher-fault-harness.mjs
- name: Gate SSH relay watcher process isolation
run: |
# Why: the remote native watcher shares a daemon with live PTYs.
# Kill only its child and require both PTY and watch recovery before packaging.
node config/scripts/relay-watcher-fault-harness.mjs
# Why: main ships minified with sourcemap:'hidden' and packaging drops
# out/**/*.map from app.asar, so a crash trace from a released build is
# otherwise undecodable. The main bundle is platform-independent, so one
-6
View File
@@ -195,12 +195,6 @@ jobs:
node config/scripts/runtime-file-watcher-fault-harness.mjs
ELECTRON_RUN_AS_NODE=1 pnpm exec electron config/scripts/runtime-file-watcher-fault-harness.mjs
- name: Gate SSH relay watcher process isolation
run: |
# Why: the remote native watcher shares a daemon with live PTYs.
# Kill only its child and require both PTY and watch recovery before packaging.
node config/scripts/relay-watcher-fault-harness.mjs
- name: Abort if the parent release-cut run was cancelled
# Why: this workflow is dispatched separately, so cancelling release-cut
# does not stop mac `--publish always`. A cancelled parent left v1.4.206
+4 -4
View File
@@ -16,7 +16,7 @@ on:
- 'src/main/ssh/ssh-remote-*'
- 'src/main/ssh/ssh-hostile-host-*'
- 'src/main/ssh/sftp-*'
- 'src/relay/**'
- 'src/wsl-guest/**'
- 'src/shared/node-runtime-pin.ts'
- 'src/shared/orcad-artifacts.ts'
- 'config/scripts/build-orcad-*.mjs'
@@ -25,7 +25,7 @@ on:
- 'config/scripts/verify-packaged-orcad-template.cjs'
- 'config/patches/node-pty*'
- '!src/**/*.test.ts'
- 'src/main/ssh/ssh-relay-hostile-hosts.docker.test.ts'
- 'src/main/ssh/ssh-hostile-hosts.docker.test.ts'
- '.github/workflows/ssh-hostile-hosts.yml'
- '.github/actions/restore-pnpm-verification/**'
workflow_dispatch:
@@ -200,7 +200,7 @@ jobs:
env:
ORCA_RUN_SSH_HOSTILE_HOSTS: '1'
ORCA_SSH_HOSTILE_HOST_CELLS: ${{ github.event.inputs.cells || '' }}
run: pnpm test:node src/main/ssh/ssh-relay-hostile-hosts.docker.test.ts
run: pnpm test:node src/main/ssh/ssh-hostile-hosts.docker.test.ts
# Design D6 on macOS: the runner is the SSH host. A user-level sshd on a loopback port logs in
# as the runner user with PATH cut to the toolchain shims and /usr/bin:/bin, so Homebrew's node
@@ -239,4 +239,4 @@ jobs:
env:
ORCA_RUN_SSH_HOSTILE_HOSTS: '1'
ORCA_SSH_HOSTILE_HOST_CELLS: ${{ matrix.cell }}
run: pnpm test:node src/main/ssh/ssh-relay-hostile-hosts.docker.test.ts
run: pnpm test:node src/main/ssh/ssh-hostile-hosts.docker.test.ts
+13 -5
View File
@@ -17,24 +17,29 @@ on:
- 'src/main/ssh/ssh-hostile-host-*'
- 'src/main/ssh/ssh-windows-host-*'
- 'src/main/ssh/ssh-session-command-audit.ts'
- 'src/relay/**'
- 'src/wsl-guest/**'
- 'src/packages/process-host/**'
- 'pnpm-workspace.yaml'
- 'src/shared/node-runtime-pin.ts'
- 'src/shared/orcad-artifacts.ts'
- 'config/scripts/build-orcad-*.mjs'
- 'config/scripts/orcad-prebuild-*.mjs'
- 'config/scripts/orcad-windows-process-tree.mjs'
- 'config/scripts/orcad-windows-cli-launcher.mjs'
- 'config/scripts/build-windows-cli-launcher.mjs'
- 'native/windows-cli-launcher/**'
- 'src/main/orcad/orcad-cli-launcher.ts'
- '.github/actions/prepare-windows-cli-launcher/**'
- 'config/scripts/build-relay.mjs'
- 'config/patches/node-pty*'
- 'config/patches/@vscode__windows-process-tree*'
- 'config/scripts/build-windows-process-tree-relay-addon.mjs'
- 'config/scripts/relay-windows-process-tree-staging.mjs'
- 'config/scripts/relay-windows-process-tree-prepared-addon*.mjs'
- 'config/scripts/windows-process-tree-gyp-rebuild.mjs'
- 'src/shared/relay-windows-breakaway-launch.ts'
- 'src/shared/windows-breakaway-launch*.ts'
- 'src/main/ipc/ssh-host-server-*.ts'
- '!src/**/*.test.ts'
- 'src/main/ssh/ssh-relay-windows-host-lane.test.ts'
- 'src/main/ssh/orcad-windows-host-lane.test.ts'
- 'tests/e2e/ssh-orcad-auto-convert.spec.ts'
- 'tests/e2e/helpers/orcad-convert-host.ts'
@@ -147,6 +152,9 @@ jobs:
with:
resolve-windows-cache: ${{ github.event_name == 'pull_request' }}
restore-windows-cache: ${{ github.event_name == 'pull_request' }}
- uses: ./.github/actions/prepare-windows-cli-launcher
with:
arches: ${{ matrix.arch }}
- name: Build the win32 template and the relay
shell: bash
env:
@@ -167,11 +175,11 @@ jobs:
$receipts=Join-Path $pwd '.build/ssh-windows-host-receipts'
New-Item -ItemType Directory -Force -Path $receipts | Out-Null
$cells=@($env:CELLS -split ',' | ForEach-Object {$_.Trim()} | Where-Object {$_})
if(-not $cells.Count){$cells=@('pinned-cmd','pinned-powershell','legacy-opt-out','orcad-cmd','orcad-powershell')}
if(-not $cells.Count){$cells=@('orcad-cmd','orcad-powershell')}
# One Windows host runs the app-level conversion; it is last because it switches native modules.
if(-not $env:CELLS -and '${{ matrix.arch }}' -eq 'x64' -and '${{ matrix.server }}' -eq 'inbox'){$cells+='orcad-convert'}
# App cells reach the managed server through an SSH local forward, which provisioning grants to the last accounts only.
$appCellIds=@('orcad-convert','orcad-cli-managed','orcad-cli-convert','orcad-cli-relay-kept')
$appCellIds=@('orcad-convert','orcad-cli-managed','orcad-cli-convert')
$cells=@($cells | Where-Object {$appCellIds -notcontains $_})+@($cells | Where-Object {$appCellIds -contains $_})
$forwarding=@($cells | Where-Object {$appCellIds -contains $_}).Count
$archive=''
+5 -1
View File
@@ -81,11 +81,15 @@ jobs:
const actionPath = '.github/actions/install-node-dependencies/action.yml'
const action = fs.existsSync(actionPath) ? fs.readFileSync(actionPath, 'utf8') : ''
const inputs = action.split(/^inputs:[ \t]*\r?$/m)[1]?.split(/^\S/m)[0] ?? ''
const supportedPostinstalls = [
'node config/scripts/rebuild-native-deps.mjs',
'pnpm run build:packages && node config/scripts/rebuild-native-deps.mjs'
]
const shared = process.env.RUNNER_KIND === 'github-hosted' && !process.env.JOB_CONTAINER &&
process.env.RUNNER_OS === 'Linux' && process.env.RUNNER_ARCH === 'X64' &&
manifest.engines?.node === '24' && typeof manifest.packageManager === 'string' &&
manifest.packageManager.split('+')[0] === 'pnpm@12.8.1' &&
manifest.scripts?.postinstall === 'node config/scripts/rebuild-native-deps.mjs' &&
supportedPostinstalls.includes(manifest.scripts?.postinstall) &&
/^ native-runtime:/m.test(inputs) && /^ cache-pnpm-store-lookup-only:/m.test(inputs) &&
fs.existsSync('.github/actions/prepare-native-runtime/action.yml') &&
fs.existsSync('config/scripts/ensure-native-runtime.mjs')
+32
View File
@@ -14,6 +14,12 @@ on:
default: ubuntu-latest
type: string
browser_contract:
description: Include the PR external Chromium browser contract in relay integration.
required: false
default: false
type: boolean
shards:
description: JSON array of shard assignments, produced by unit-plan.yml.
required: true
@@ -96,6 +102,7 @@ jobs:
persist-credentials: false
- uses: ./.github/actions/install-node-dependencies
id: root_dependencies
with:
native-runtime: none
node-version: ${{ matrix.node }}
@@ -103,15 +110,40 @@ jobs:
pnpm-lock.yaml
cloud/pnpm-lock.yaml
- name: Resolve Chrome for the browser provider
id: browser_executable
if: inputs.browser_contract && success()
run: |
set -euo pipefail
chrome="$(command -v google-chrome || command -v google-chrome-stable || true)"
if [ -z "$chrome" ]; then
echo "::error::No Google Chrome on the runner; the browser provider test would silently skip."
exit 1
fi
"$chrome" --version
echo "executable=$chrome" >> "$GITHUB_OUTPUT"
- name: Test external Chromium browser provider
if: inputs.browser_contract && success()
env:
ORCA_BACKGROUND_LAUNCH: '1'
ORCA_BROWSER_EXECUTABLE: ${{ steps.browser_executable.outputs.executable }}
run: |
pnpm exec vitest run --config config/vitest.config.ts \
src/main/orcad/external-chromium-browser-process.integration.test.ts
# These two tests import the cloud relay workspace directly. Keeping them in one job
# avoids installing and building the same workspace once per unit-test shard.
- name: Install relay integration dependencies
id: relay_dependencies
if: ${{ !cancelled() && steps.root_dependencies.outcome == 'success' }}
working-directory: cloud
run: |
npx --yes pnpm@10.34.6 --filter '@orca-cloud/relay...' install --frozen-lockfile --ignore-scripts
npx --yes pnpm@10.34.6 --filter '@orca-cloud/relay^...' build
- name: Test relay integration contracts
if: ${{ !cancelled() && steps.relay_dependencies.outcome == 'success' }}
env:
ORCA_BACKGROUND_LAUNCH: '1'
run: >-
@@ -11,6 +11,7 @@ on:
types: [opened, synchronize, reopened, ready_for_review]
paths:
- 'src/main/daemon/**'
- '!src/main/daemon/**/*.test.ts'
- 'src/main/orcad/**'
- 'src/cli/runtime/**'
- 'src/shared/orcad-local-serve-selection.ts'
@@ -77,6 +78,9 @@ jobs:
with:
resolve-windows-cache: 'true'
restore-windows-cache: 'true'
- uses: ./.github/actions/prepare-windows-cli-launcher
with:
arches: x64
- name: Build the win32-x64 orcad template
shell: bash
run: |
@@ -0,0 +1,36 @@
name: ZCode read-only account contract
on:
pull_request:
paths:
- 'src/main/zcode/**'
- 'src/shared/node-bounded-file-reader*'
- 'src/main/rate-limits/**'
- '.github/workflows/zcode-account-contract.yml'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: zcode-account-contract-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
credential-files:
strategy:
fail-fast: false
matrix:
os: [ubuntu-22.04, macos-15, windows-2022]
runs-on: ${{ matrix.os }}
timeout-minutes: 20
env:
ORCA_BACKGROUND_LAUNCH: '1'
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: node
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version-file: config/.bun-version
- name: Verify read-only credentials and account changes
run: pnpm test src/main/zcode/zcode-v2-plan-credentials.test.ts src/main/zcode/zcode-v2-personal-overlay-raw-json.test.ts src/main/rate-limits/service-zcode-usage.test.ts src/shared/node-bounded-file-reader-sync.test.ts
@@ -0,0 +1,37 @@
name: Coding Plan credential contract
on:
pull_request:
paths:
- 'src/main/zcode/**'
- 'src/shared/secure-file*'
- 'src/shared/secure-path-windows-acl*'
- 'src/shared/windows-security-descriptor*'
- '.github/workflows/zcode-credential-contract.yml'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: coding-plan-contract-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
credential-files:
strategy:
fail-fast: false
matrix:
os: [ubuntu-22.04, macos-15, windows-2022]
runs-on: ${{ matrix.os }}
timeout-minutes: 20
env:
ORCA_BACKGROUND_LAUNCH: '1'
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: node
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version-file: config/.bun-version
- name: Verify rollback and native Windows permissions
run: pnpm test src/main/zcode/zcode-plan-api-key-store.test.ts src/main/zcode/zcode-plan-api-key-store-plaintext-rollback.test.ts src/main/zcode/zcode-plan-api-key-store-publication-race.test.ts src/main/zcode/zcode-plan-api-key-store-native-windows.test.ts src/shared/secure-file.test.ts
@@ -0,0 +1,40 @@
name: ZCode transcript contract
on:
pull_request:
paths:
- 'src/main/ai-vault/session-scanner-zcode*'
- 'src/main/ai-vault/session-scanner-opencode*'
- 'src/main/ai-vault/opencode-wsl-runtime*'
- 'src/main/native-chat/transcript-opencode*'
- 'src/main/native-chat/transcript-zcode*'
- 'src/shared/native-chat-types.ts'
- 'src/shared/native-chat-transcript-projection*'
- '.github/workflows/zcode-transcript-contract.yml'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: zcode-transcript-contract-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
transcript-readers:
strategy:
fail-fast: false
matrix:
os: [ubuntu-22.04, macos-15, windows-2022]
runs-on: ${{ matrix.os }}
timeout-minutes: 20
env:
ORCA_BACKGROUND_LAUNCH: '1'
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: node
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version-file: config/.bun-version
- name: Verify SQLite ordering, legacy readers, and host routing
run: pnpm test src/main/native-chat/transcript-zcode.test.ts src/main/native-chat/transcript-opencode.test.ts src/main/native-chat/transcript-opencode-sqlite-query.test.ts src/main/native-chat/transcript-opencode2-sqlite.test.ts src/main/native-chat/transcript-opencode-semantic-fidelity.test.ts src/main/native-chat/transcript-opencode-read-bounds.test.ts src/main/native-chat/transcript-opencode-subscribe.test.ts src/main/ai-vault/session-scanner-zcode.test.ts src/main/ai-vault/session-scanner-zcode-sources.test.ts src/main/ai-vault/opencode-wsl-runtime-preparation.test.ts src/main/ai-vault/session-scanner-opencode-wsl-routing.test.ts src/shared/native-chat-transcript-projection.test.ts src/renderer/src/components/native-chat/structured-agent-session-transcript-order.test.ts
+5
View File
@@ -169,3 +169,8 @@ tests/e2e/.cross-version-checkouts/
# Generated by the CI unit-test sequencer (and by reproducing a shard locally).
ci-shards/
# Paired CI timing reports and temporary baseline plugin.
.build/preflight-benchmark/
.local/static-analysis-research/
+28
View File
@@ -140,6 +140,34 @@
"unicorn/throw-new-error": "error"
},
"overrides": [
{
"files": ["**/*.{js,cjs,mjs,jsx,ts,tsx,mts,cts}"],
"excludeFiles": [
"**/*.{test,spec}.{js,cjs,mjs,jsx,ts,tsx,mts,cts}",
"**/__tests__/**",
"src/renderer/src/runtime/web-runtime-browser-creation-placement-test-rig.ts"
],
"rules": {
"no-restricted-imports": [
"error",
{
"paths": [
{
"name": "@linear/sdk",
"allowTypeImports": true,
"message": "Value-importing @linear/sdk hoists its ~2.6MB CJS bundle into the eager top-level require block and defeats the lazy loader. Use `import type` plus loadLinearSdk() from src/main/linear/linear-sdk.ts."
}
],
"patterns": [
{
"regex": "(?i)(?:^|[/\\\\])(?:web-session-tabs-sync-test-harness|web-runtime-browser-creation-placement-test-rig)(?:\\.[cm]?[jt]sx?)?(?:[?#].*)?$",
"message": "This module is unit-test support. Import the production web-session owner instead."
}
]
}
]
}
},
{
"files": ["src/renderer/src/**/*.{ts,tsx}"],
"rules": {
+1 -1
View File
@@ -75,7 +75,7 @@ Orca targets macOS, Linux, and Windows. Keep all platform-dependent behavior beh
- **File paths**: Use `path.join` or Electron/Node path utilities — never assume `/` or `\`.
- **Windows terminal shells**: `--shell` picks the shell a terminal _is_; `--command` is typed into whatever shell the host spawned, so a shell choice routed through `command` silently becomes a child process.
- **Windows setup scripts**: the setup/issue-command runner is a `.cmd` batch file unless the script starts with a `#!` line — never derive that from the user's terminal-shell preference, and never launch a `.cmd` runner with a bare `cmd.exe /c` from a Git Bash pane (MSYS rewrites the `/c`).
- **Windows child processes**: start them through `runProcess`/`spawnProcess` in `src/shared/child-process/` — never `child_process` directly. It pins `windowsHide`, refuses `shell: true`, and encodes `.cmd`/`.bat` arguments so neither `CommandLineToArgvW` nor `cmd.exe` mangles them. Recognised npm/pnpm `.cmd` shims are resolved to their real target so the spawn skips `cmd.exe` entirely.
- **Windows child processes**: start them through `runProcess`/`spawnProcess` from `@orca/process-host` — never `child_process` directly. It pins `windowsHide`, refuses `shell: true`, and encodes `.cmd`/`.bat` arguments so neither `CommandLineToArgvW` nor `cmd.exe` mangles them. Recognised npm/pnpm `.cmd` shims are resolved to their real target so the spawn skips `cmd.exe` entirely.
- **Ripgrep**: Orca bundles `rg` for every platform, WSL, and SSH remotes. Spawn it through `spawnBundledRipgrep` (main) or `resolveRelayRipgrepCommand` (relay), never a bare `'rg'` — Windows resolves a bare name in the spawn cwd before PATH. Don't add git/readdir fallbacks locally; the relay's chain exists only for hosts an upload never reached.
- **Windows process enumeration**: read the table through `src/main/windows/windows-process-table.ts`, never by forking `powershell.exe`.
- **Windows MSYS/Git Bash panes**: their children break away from the per-PTY job unless it is created without `JOB_OBJECT_LIMIT_BREAKAWAY_OK`, and a `conpty.node` built before that fix passes every existing gate.
@@ -6,6 +6,7 @@ export type PushOrcaData = {
hostFingerprint: string
worktreeId?: string
paneKey?: string
executionHost?: string
notificationId?: string
notificationSeq: number
notificationEpoch: string
@@ -54,6 +55,9 @@ export function buildPushDelivery(input: {
hostFingerprint,
...(notification.paneKey === undefined ? {} : { paneKey: notification.paneKey }),
...(notification.worktreeId === undefined ? {} : { worktreeId: notification.worktreeId }),
...(notification.executionHost === undefined
? {}
: { executionHost: notification.executionHost }),
...(notification.notificationId === undefined
? {}
: { notificationId: notification.notificationId }),
@@ -0,0 +1,35 @@
import { expect, it } from 'vitest'
import { PushNotificationSchema } from '@orca-cloud/push-contract'
import { buildPushDelivery, orcaDataStrings } from './push-delivery-message.js'
const base = {
notificationSeq: 1,
notificationEpoch: 'epoch',
source: 'agent-task-complete',
agentState: 'finished',
title: 'Done',
body: '',
worktreeId: 'repo-1::/srv/work'
}
it('carries the workspace server to APNs and FCM, and accepts older messages without one', () => {
const executionHost = 'runtime:env%201'
for (const extra of [{}, { executionHost }]) {
const delivery = buildPushDelivery({
notification: PushNotificationSchema.parse({ ...base, ...extra }),
hostFingerprint: 'host',
registrationId: 'phone',
expiresAt: Date.now() + 300000
})
const expected = 'executionHost' in extra ? executionHost : undefined
expect(delivery.orca.executionHost).toBe(expected)
expect(orcaDataStrings(delivery.orca).executionHost).toBe(expected)
}
})
it('accepts only runtime execution hosts', () => {
const invalid = ['local', 'ssh:box', 'runtime:', 'runtime:a|b', `runtime:${'x'.repeat(2048)}`]
for (const executionHost of invalid) {
expect(PushNotificationSchema.safeParse({ ...base, executionHost }).success).toBe(false)
}
})
@@ -17,6 +17,7 @@ const mutationRoutes = [
'/v1/admin/admission-selector/apply',
'/v1/admin/admission-selector/add-migration-cells',
'/v1/admin/cell-state',
'/v1/admin/cell-admit-mode',
'/v1/admin/cell-fence-adopt-legacy',
'/v1/admin/cell-fence-commit-legacy-adoption',
'/v1/admin/cell-fence-attest',
@@ -67,6 +68,13 @@ describe('Relay admin route authorization', () => {
expect(relayAdminIdentityMayAccess('monitor', route)).toBe(false)
}
expect(RELAY_MONITOR_ADMIN_ROUTES).toContain('/v1/admin/evacuation-status')
// Read-only on-call lookup: monitor and fence read it, capacity and asia-proof do not.
for (const identity of ['monitor', 'fence', 'fence-broker', 'deploy'] as const) {
expect(relayAdminIdentityMayAccess(identity, '/v1/admin/host-whereabouts')).toBe(true)
}
for (const identity of ['capacity', 'asia-proof'] as const) {
expect(relayAdminIdentityMayAccess(identity, '/v1/admin/host-whereabouts')).toBe(false)
}
})
it('allows only reviewed fence evidence mutations beyond aggregate reads', () => {
@@ -5,6 +5,7 @@ export const RELAY_MONITOR_ADMIN_ROUTES = [
'/v1/admin/admission-selector/status',
'/v1/admin/cell-status',
'/v1/admin/evacuation-status',
'/v1/admin/host-whereabouts',
'/v1/admin/regional-rehome-control',
'/v1/admin/regional-rehome-preview',
'/v1/admin/runtime-status'
+315 -25
View File
@@ -17,7 +17,7 @@ import {
type RelayRegion
} from '@orca-cloud/relay-contract'
import { Hono, type Context } from 'hono'
import { SignJWT } from 'jose'
import { SignJWT, type createRemoteJWKSet } from 'jose'
import { z } from 'zod'
import {
createAdminTokenVerifier,
@@ -29,14 +29,23 @@ import {
} from './admin-token-verifier.js'
import {
RelayAssignmentRowBusyError,
RelayCellInReserveModeError,
RelayHomeCellUnavailableError,
type CellFenceAttemptEvidence,
type RelayAssignment,
type RelayAssignmentStore,
type ResolvedRelayAssignment
} from './assignment-store.js'
import { ASSIGNMENT_LEASE_AUDIENCE, assignmentLeaseKeyId } from './assignment-lease.js'
import { AssignmentRejectionLogWindow } from './assignment-rejection-log-window.js'
import { CELL_ADMISSION_STATES } from './cell-admission-selector.js'
import { registerCellSeatFeedRoute } from './cell-seat-feed-route.js'
import { ReservePaceError, type ReserveAssignment, type ReservePlan } from './reserve-assignment.js'
import { registerCellReserveRoutes } from './cell-reserve-routes.js'
import type { DemoteRequest, ReserveOutcome, ReserveRequest } from './cell-reserve-contract.js'
import type { CellSeatFeedPage } from './cell-seat-log.js'
import { type CellFlags, supportedCellFlags } from './cell-flags.js'
import type { AppliedControlFlags } from './relay-control-flag-channel.js'
import { RELAY_MAX_CELL_CAPACITY_REQUESTS, type RelayConfig } from './config.js'
import type { RelayCredentialStore } from './credential-store.js'
import { isRelayDatabaseTransientError } from './database.js'
@@ -50,6 +59,13 @@ import {
type AssignmentAdmissionRejection
} from './public-assignment-admission.js'
import { relayHostLogDigest } from './relay-host-log-digest.js'
import type { RelayLocalReadinessVerdict } from './relay-local-readiness.js'
import {
classifyShadowSeat,
type ShadowCompareDatabaseAnswer,
type ShadowCompareRoute
} from './shadow-directory-compare.js'
import type { ShadowSeatDirectory } from './shadow-seat-directory.js'
import type { RelayReadinessDependency } from './relay-readiness.js'
import type {
AssignmentAdmissionLane,
@@ -112,11 +128,29 @@ export function createRelayApp(
regionalRehomeFetch?: typeof fetch
regionalRehomeTrustProbeHostExists?: (input: { userId: string; relayHostId: string }) => boolean
cellIncarnation?: string
cellSeatFeed?: (sinceSeq: number | null) => CellSeatFeedPage
cellFlags?: () => AppliedControlFlags<CellFlags>
// Step 5 on directors; absent keeps today's path for every request.
reservePlacement?: ReserveAssignment
cellReserve?: (request: ReserveRequest) => ReserveOutcome[]
cellDemote?: (request: DemoteRequest) => string
cellReserverPoll?: () => void
cellAdmitModeEffective?: () => 'db' | 'reserve'
cellAdmitModeRaw?: () => 'db' | 'reserve'
cellReserveCounts?: () => {
bookings: number
intake: { perSec: number; burst: number; tokens: number }
} | null
cellPlacementCeiling?: number
isDraining?: () => boolean
regionalRehomeSafetySnapshot?: () => RegionalRehomeSafetySnapshot
runtimeCounts?: () => RelayRuntimeCounts
ready: () => Promise<boolean>
readinessDegradation?: () => RelayReadinessDependency[]
// Both only on cells; the switch is the per-cell `readinessLocal` flag, off by default.
readinessLocal?: () => boolean
localReadiness?: () => RelayLocalReadinessVerdict
relayJwks?: ReturnType<typeof createRemoteJWKSet>
recordAssignmentAdmission?: (outcome: AssignmentAdmissionOutcome) => void
recordAssignmentRejectionReason?: (
lane: AssignmentAdmissionLane,
@@ -126,6 +160,13 @@ export function createRelayApp(
recordAdmissionServiceMs?: (lane: 'sticky' | 'drain-return', durationMs: number) => void
recordAssignmentUnavailable?: (cause: AssignmentUnavailableCause) => void
recordRegionRequest?: (region: RelayRegion | undefined) => void
// Step 3 shadow directory; absent unless the director polls seat feeds.
shadowSeats?: ShadowSeatDirectory
compareShadowSeats?: (
route: ShadowCompareRoute,
identity: { userId: string; relayHostId: string },
answer: ShadowCompareDatabaseAnswer
) => void
recordRegionSelection?: (input: {
targetRegion: RelayRegion
selectedRegion?: RelayRegion
@@ -155,7 +196,9 @@ export function createRelayApp(
regionCatalogRefresh = undefined
}
}
const verifyRelayToken = createRelayTokenVerifier(config)
// Shared with the host-control upgrade and local readiness: one cached key set per process.
const verifyRelayToken = createRelayTokenVerifier(config, operations.relayJwks)
const assignmentLeaseKid = assignmentLeaseKeyId(config.assignmentSigningKey)
const verifyAdminToken = createAdminTokenVerifier(config)
const verifyReadOnlyAdminToken = createReadOnlyAdminTokenVerifier(config)
const verifyRegionalRehomeControlApplyToken =
@@ -187,6 +230,24 @@ export function createRelayApp(
minIntervalMs: config.publicAssignmentRetryAfterSeconds * 1_000,
onRejected: (reason) => operations.recordAssignmentRejectionReason?.('placement', reason)
})
// Runs after the database answer and never changes or fails the response.
const compareShadowSeats: NonNullable<typeof operations.compareShadowSeats> = (
route,
identity,
answer
) => {
try {
operations.compareShadowSeats?.(route, identity, answer)
} catch (error) {
console.warn(
JSON.stringify({
event: 'orca_relay_shadow_compare_failed',
route,
reason: error instanceof Error ? error.message : 'unknown'
})
)
}
}
const rejectPublicAssignment = (context: Context): Response => {
context.header('Retry-After', String(config.publicAssignmentRetryAfterSeconds))
return context.json({ error: 'assignments_temporarily_unavailable' }, 503)
@@ -239,7 +300,9 @@ export function createRelayApp(
error: unknown,
status: 404 | 409
): Response => {
if (!isRelayDatabaseTransientError(error)) {
// The reserve-mode set could not be read: retry, as for a database blip.
const unknownReserveSet = error instanceof RelayCellInReserveModeError && error.cellId === null
if (!isRelayDatabaseTransientError(error) && !unknownReserveSet) {
return context.json({ error: operationError(error) }, status)
}
context.header('Retry-After', String(config.publicAssignmentRetryAfterSeconds))
@@ -268,7 +331,9 @@ export function createRelayApp(
app.use('/v1/admin/*', async (context, next) => {
if (
context.req.path === '/v1/admin/cell-heartbeat' ||
context.req.path === '/v1/admin/cell-rehome-status'
context.req.path === '/v1/admin/cell-rehome-status' ||
// Its own route checks the rehome identity; an admin check first would verify each poll twice.
context.req.path === '/v1/admin/cell-seats'
) {
return await next()
}
@@ -280,6 +345,25 @@ export function createRelayApp(
return await next()
})
registerCellSeatFeedRoute(app, config, {
verifyRegionalRehomeToken,
cellIncarnation: operations.cellIncarnation,
seatFeed: operations.cellSeatFeed,
isDraining: operations.isDraining,
runtimeCounts: operations.runtimeCounts,
cellFlags: operations.cellFlags,
onReserverPoll: operations.cellReserverPoll,
admitModeEffective: operations.cellAdmitModeEffective,
admitModeRaw: operations.cellAdmitModeRaw,
reserveCounts: operations.cellReserveCounts,
placementCeiling: operations.cellPlacementCeiling
})
registerCellReserveRoutes(app, config, {
verifyRegionalRehomeToken,
reserve: operations.cellReserve,
demote: operations.cellDemote
})
// Not /healthz: Google Front End reserves that path before the container.
// The drain pace cap is read before a roll isolates a cell, so a slower pace than the cell
// accepts stops with the cell untouched.
@@ -291,7 +375,15 @@ export function createRelayApp(
})
)
app.get('/ready', async (context) => {
if (!(await operations.ready())) return context.json({ error: 'dependency_unavailable' }, 503)
const ready = await operations.ready()
if (operations.localReadiness && operations.readinessLocal?.()) {
// The probes still ran above, so their failures are still reported, but never decide.
const local = operations.localReadiness()
if (!local.ready) return context.json({ error: local.reason }, 503)
if (local.failing.length === 0) return context.json({ ok: true })
return context.json({ ok: true, degraded: true, dependency: local.failing })
}
if (!ready) return context.json({ error: 'dependency_unavailable' }, 503)
const dependency = operations.readinessDegradation?.() ?? []
// Still the 200 the load balancer needs, with the marker that says the answer is remembered.
if (dependency.length === 0) return context.json({ ok: true })
@@ -335,6 +427,56 @@ export function createRelayApp(
? requestedRegion
: RELAY_DEFAULT_REGION
operations.recordRegionRequest?.(requestedRegion)
const signAssignment = async (assignment: RelayAssignment) =>
await new SignJWT({
purpose: 'cell-assignment',
cellId: assignment.cellId,
cellUrl: assignment.cellUrl,
assignmentEpoch: assignment.assignmentEpoch,
relayHostId: claims.relayHostId
})
.setProtectedHeader({ alg: 'HS256', kid: assignmentLeaseKid })
.setIssuer(config.publicUrl)
.setAudience(ASSIGNMENT_LEASE_AUDIENCE)
.setSubject(claims.sub)
.setIssuedAt()
.setExpirationTime('5m')
.sign(config.assignmentSigningKey)
// Step 5: switched-on cells answer from the map; anything else keeps today's path below.
let step5: { epochFloor?: number; placeFresh?: () => Promise<RelayAssignment | null> } = {}
if (operations.reservePlacement && !body.data.regionCorrection) {
let plan: ReservePlan
try {
plan = await operations.reservePlacement.plan(identity, {
reconnect: Boolean(body.data.reconnect),
region: targetRegion
})
} catch (error) {
if (!(error instanceof ReservePaceError)) throw error
plan = { kind: 'retry', retryAfterSeconds: error.retryAfterSeconds, reason: 'paced' }
}
if (plan.kind === 'answer') {
operations.recordAssignmentAdmission?.(plan.lane === 'sticky' ? 'sticky' : 'placement')
return context.json({
v: 1,
cellUrl: plan.assignment.cellUrl,
assignmentEpoch: plan.assignment.assignmentEpoch,
lease: await signAssignment(plan.assignment)
})
}
if (plan.kind === 'retry') {
operations.recordAssignmentUnavailable?.(
plan.reason === 'map-incomplete'
? 'reserve-map-incomplete'
: plan.reason === 'database'
? 'reserve-database'
: 'reserve-paced'
)
context.header('Retry-After', String(plan.retryAfterSeconds))
return context.json({ error: 'assignment_temporarily_unavailable' }, 503)
}
step5 = { epochFloor: plan.epochFloor, placeFresh: plan.placeFresh }
}
let admission: { release(): void } | null = null
let lane: AssignmentAdmissionLane = 'placement'
if (body.data.reconnect) {
@@ -376,6 +518,7 @@ export function createRelayApp(
}
throw error
}
compareShadowSeats('sticky-verify', identity, verified)
if (verified?.homeCellRollIsolated && drainReturnConcurrency > 0) {
// The sticky slot covered only the verification read; the re-placement
// that follows is the drain lane's work, not the sticky lane's.
@@ -434,9 +577,19 @@ export function createRelayApp(
if (!current) return context.json({ error: 'assignment_not_found' }, 409)
assignment = current
} else {
assignment = requestedRegion
? await operations.assignments.assign(identity, requestedRegion, targetRegion)
: await operations.assignments.assign(identity)
// Without step 5 the call is exactly today's.
const step5Active = step5.epochFloor !== undefined || step5.placeFresh !== undefined
assignment = step5Active
? await operations.assignments.assign(
identity,
requestedRegion,
requestedRegion ? targetRegion : undefined,
undefined,
step5
)
: requestedRegion
? await operations.assignments.assign(identity, requestedRegion, targetRegion)
: await operations.assignments.assign(identity)
}
if (body.data.regionCorrection) {
try {
@@ -452,6 +605,11 @@ export function createRelayApp(
}
}
} catch (error) {
if (error instanceof ReservePaceError) {
operations.recordAssignmentUnavailable?.('reserve-paced')
context.header('Retry-After', String(error.retryAfterSeconds))
return context.json({ error: 'assignment_temporarily_unavailable' }, 503)
}
if (error instanceof RelayAssignmentRowBusyError) {
logAssignmentRejection({
route: 'assign',
@@ -508,20 +666,7 @@ export function createRelayApp(
` host=${relayHostLogDigest(claims.relayHostId)} cell=${assignment.cellId}`
)
}
const lease = await new SignJWT({
purpose: 'cell-assignment',
cellId: assignment.cellId,
cellUrl: assignment.cellUrl,
assignmentEpoch: assignment.assignmentEpoch,
relayHostId: claims.relayHostId
})
.setProtectedHeader({ alg: 'HS256' })
.setIssuer(config.publicUrl)
.setAudience('orca-relay-cell')
.setSubject(claims.sub)
.setIssuedAt()
.setExpirationTime('5m')
.sign(config.assignmentSigningKey)
const lease = await signAssignment(assignment)
return context.json({
v: 1,
cellUrl: assignment.cellUrl,
@@ -568,9 +713,9 @@ export function createRelayApp(
relayHostId: body.data.relayHostId
}
// This also migrates credentials created by the staging-only combined service.
const assignment =
(await operations.assignments.resolve(identity)) ??
(await operations.assignments.assign(identity))
const resolvedAssignment = await operations.assignments.resolve(identity)
compareShadowSeats('resolve', identity, resolvedAssignment)
const assignment = resolvedAssignment ?? (await operations.assignments.assign(identity))
return context.json({
v: 1,
cellUrl: assignment.cellUrl,
@@ -717,6 +862,22 @@ export function createRelayApp(
imageDigest: config.imageDigest ?? null,
draining: operations.isDraining?.() ?? false,
regionalRehomeProtocol: config.rehomeAudience && config.rehomeDirectorServiceAccount ? 3 : 0,
// The flag workflow's read-back: applied switches, never the desired object.
...(operations.cellFlags
? {
flagsApplied: operations.cellFlags(),
supportedFlags: supportedCellFlags(),
// The flag tool paces a flip back's wait from it.
databasePoolMax: config.databasePoolMax
}
: {}),
...(operations.cellAdmitModeEffective
? { admitModeEffective: operations.cellAdmitModeEffective() }
: {}),
// Placement as built, not as configured: `on` only when this director books reserve cells.
...(config.role === 'director'
? { reservePlacement: operations.reservePlacement?.placementMode ?? 'off' }
: {}),
connectionCapacity:
config.connectionHardCap === undefined
? null
@@ -1066,6 +1227,44 @@ export function createRelayApp(
return rejectAdminOperation(context, error, 409)
}
})
// Step 5: the flag workflow's record of a cell's admitMode, which every sweep reads. Without
// `admitMode` it only reads; reserve is refused while a drain, migration or rehome is open.
app.post('/v1/admin/cell-admit-mode', async (context) => {
const bearer = readBearer(context.req.header('authorization'))
if (config.role !== 'director') return context.json({ error: 'director_only' }, 404)
if (!bearer || !(await verifyAdminToken(bearer))) {
return context.json({ error: 'invalid_token' }, 401)
}
if (requestTooLarge(context.req.header('content-length'))) {
return context.json({ error: 'request_too_large' }, 413)
}
const body = AdminCellAdmitModeSchema.safeParse(await context.req.json().catch(() => null))
if (!body.success) return context.json({ error: 'invalid_request' }, 400)
try {
if (body.data.admitMode) {
const before = await operations.assignments.cellAdmitMode(body.data.cellId)
await operations.assignments.setCellAdmitMode(body.data.cellId, body.data.admitMode)
// The sweeps' switch: every write is on the record, the break-glass flip included.
console.warn(
JSON.stringify({
event: 'orca_relay_cell_admit_mode_recorded',
cellId: body.data.cellId,
from: before.admitMode,
to: body.data.admitMode
})
)
}
return context.json({
v: 1,
cellId: body.data.cellId,
...(await operations.assignments.cellAdmitMode(body.data.cellId)),
// The break-glass flip refuses a cell that is still heartbeating.
heartbeatFresh: await operations.assignments.cellHeartbeatFresh(body.data.cellId)
})
} catch (error) {
return rejectAdminOperation(context, error, 409)
}
})
app.post('/v1/admin/cell-fence-adopt-legacy', async (context) => {
const bearer = readBearer(context.req.header('authorization'))
if (config.role !== 'director') return context.json({ error: 'director_only' }, 404)
@@ -1466,6 +1665,85 @@ export function createRelayApp(
return context.json({ error: 'database_temporarily_unavailable' }, 503)
}
})
// Per director instance: tooling may ask more than one to see every map.
app.post('/v1/admin/host-whereabouts', async (context) => {
const bearer = readBearer(context.req.header('authorization'))
if (config.role !== 'director') return context.json({ error: 'director_only' }, 404)
if (!bearer || !(await verifyAdminToken(bearer, context.req.path))) {
return context.json({ error: 'invalid_token' }, 401)
}
if (requestTooLarge(context.req.header('content-length'))) {
return context.json({ error: 'request_too_large' }, 413)
}
const body = AdminHostWhereaboutsSchema.safeParse(await context.req.json().catch(() => null))
if (!body.success) return context.json({ error: 'invalid_request' }, 400)
const { relayHostId } = body.data
const shadow = operations.shadowSeats
const now = Date.now()
// Without a user id only the map can name one: relay_assignments has no host-id index.
const userIds = body.data.userId ? [body.data.userId] : (shadow?.userIdsOf(relayHostId) ?? [])
try {
const cellIds = new Set<string>()
const users = []
for (const userId of userIds) {
const identity = { userId, relayHostId }
const database = await operations.assignments.hostWhereabouts(identity)
const seats = shadow?.seatsOf(userId, relayHostId) ?? []
const recentlyLeft = shadow?.recentlyLeftOf(userId, relayHostId, now) ?? []
if (database) cellIds.add(database.cellId)
if (database?.openMigration) {
cellIds.add(database.openMigration.sourceCellId)
cellIds.add(database.openMigration.targetCellId)
}
for (const entry of [...seats, ...recentlyLeft]) cellIds.add(entry.cellId)
users.push({
userId,
database,
seats,
recentlyLeft,
// As /v1/resolve sees it: a row on a cell that is not live reads as no answer.
verdict: shadow
? classifyShadowSeat(
shadow,
identity,
database && shadow.cellLiveness(database.cellId, now) !== 'unlive'
? database
: null,
now
)
: null
})
}
const cells = Object.fromEntries(
[...cellIds].sort().map((cellId) => {
const state = shadow?.cellState(cellId)
return [
cellId,
state
? {
status: state.status,
pollAgeMs: state.lastLiveAt === undefined ? null : now - state.lastLiveAt,
lastFailure: state.lastFailure ?? null,
draining: state.draining ?? null,
flagsApplied: state.flagsApplied ?? null
}
: null
]
})
)
return context.json({
v: 1,
directorIncarnation: operations.cellIncarnation ?? null,
relayHostId,
shadow: shadow ? { state: 'on', complete: shadow.isComplete() } : { state: 'off' },
users,
cells
})
} catch (error) {
if (!isRelayDatabaseTransientError(error)) throw error
return context.json({ error: 'database_temporarily_unavailable' }, 503)
}
})
app.post('/v1/admin/cell-status', async (context) => {
const bearer = readBearer(context.req.header('authorization'))
if (config.role !== 'director') return context.json({ error: 'director_only' }, 404)
@@ -1487,6 +1765,14 @@ export function createRelayApp(
return app
}
const AdminHostWhereaboutsSchema = z
.object({
v: z.literal(1),
relayHostId: z.string().regex(/^[A-Za-z0-9_-]{16}$/),
userId: z.string().min(1).max(256).optional()
})
.strict()
const AdminAssignmentMoveSchema = z
.object({
v: z.literal(1),
@@ -1801,6 +2087,10 @@ const AdminAdmissionSelectorAddMigrationCellsSchema = z
new Set(cells.map(({ cellUrl }) => cellUrl)).size === cells.length
)
const AdminCellAdmitModeSchema = z
.object({ v: z.literal(1), cellId: CellIdSchema, admitMode: z.enum(['db', 'reserve']).optional() })
.strict()
const AdminCellStateSchema = z.union([
z.object({ v: z.literal(1), cellId: CellIdSchema, enabled: z.boolean() }).strict(),
z.object({ v: z.literal(1), cellId: CellIdSchema, state: CellAdmissionStateSchema }).strict()
@@ -0,0 +1,99 @@
import { percentile } from './relay-observability.js'
import { classifyAssignmentLease, type AssignmentLeaseClass } from './assignment-lease.js'
// What `ticketCheck=enforce` would have said, beside the database answer that still decides.
// `agree`/`disagree` replace `valid`: a valid lease the database refused is the case step 5
// has to explain before a lease alone may admit a host.
export type AssignmentLeaseShadowClass = Exclude<AssignmentLeaseClass, 'valid'> | 'agree' | 'disagree'
export const ASSIGNMENT_LEASE_SHADOW_EVENT = 'orca_relay_assignment_lease_shadow'
const WINDOW_MS = 60_000
// Flat per-class totals under `classes`, so a log-based metric can extract each by path.
const CLASS_FIELDS: ReadonlyArray<readonly [AssignmentLeaseShadowClass, string]> = [
['agree', 'agree'],
['disagree', 'disagree'],
['absent', 'absent'],
['expired', 'expired'],
['bad-signature', 'badSignature'],
['wrong-host', 'wrongHost'],
['wrong-cell', 'wrongCell'],
['epoch-behind', 'epochBehind'],
['epoch-ahead', 'epochAhead']
]
const MAX_DB_READ_SAMPLES = 2_000
export class AssignmentLeaseShadow {
// Hellos the database refused, whatever the lease said; `disagree` is the valid-lease part.
private dbRefused = 0
private classes = new Map<AssignmentLeaseShadowClass, number>()
private dbReadMs: number[] = []
private windowStartedAt: number
constructor(
private readonly input: {
enabled: () => boolean
key: Uint8Array
cellId: string
now?: () => number
log?: (line: string) => void
}
) {
this.windowStartedAt = this.now()
}
// Null when the switch is off, so the hello path does no extra work.
check(input: {
lease: string | undefined
userId: string
relayHostId: string
helloEpoch: number
}): Promise<AssignmentLeaseClass> | null {
if (!this.input.enabled()) return null
return classifyAssignmentLease({ ...input, key: this.input.key, cellId: this.input.cellId })
}
record(check: Promise<AssignmentLeaseClass>, dbValid: boolean, dbReadMs: number): void {
void check
.then((leaseClass) => {
const shadowClass: AssignmentLeaseShadowClass =
leaseClass === 'valid' ? (dbValid ? 'agree' : 'disagree') : leaseClass
if (!dbValid) this.dbRefused += 1
this.classes.set(shadowClass, (this.classes.get(shadowClass) ?? 0) + 1)
if (this.dbReadMs.length < MAX_DB_READ_SAMPLES) this.dbReadMs.push(dbReadMs)
this.flushIfDue()
})
.catch(() => undefined)
}
private flushIfDue(): void {
const now = this.now()
if (now - this.windowStartedAt < WINDOW_MS) return
const log = this.input.log ?? console.log
log(
JSON.stringify({
event: ASSIGNMENT_LEASE_SHADOW_EVENT,
cellId: this.input.cellId,
windowMs: now - this.windowStartedAt,
classes: Object.fromEntries(
CLASS_FIELDS.map(([shadowClass, field]) => [field, this.classes.get(shadowClass) ?? 0])
),
dbRefused: this.dbRefused,
// The hello's database read, which a lease-admitted hello would skip (E-pre).
dbReadMs: {
samples: this.dbReadMs.length,
p50: percentile(this.dbReadMs, 0.5),
p99: percentile(this.dbReadMs, 0.99),
max: percentile(this.dbReadMs, 1)
}
})
)
this.dbRefused = 0
this.classes = new Map()
this.dbReadMs = []
this.windowStartedAt = now
}
private now(): number {
return (this.input.now ?? Date.now)()
}
}
@@ -0,0 +1,255 @@
import { createHash } from 'node:crypto'
import { EventEmitter } from 'node:events'
import { RELAY_CLOSE_CODE } from '@orca-cloud/relay-contract'
import { SignJWT } from 'jose'
import nacl from 'tweetnacl'
import { describe, expect, it, vi } from 'vitest'
import type WebSocket from 'ws'
import {
ASSIGNMENT_LEASE_AUDIENCE,
assignmentLeaseKeyId,
classifyAssignmentLease
} from './assignment-lease.js'
import { ASSIGNMENT_LEASE_SHADOW_EVENT, AssignmentLeaseShadow } from './assignment-lease-shadow.js'
import type { RelayAssignmentStore } from './assignment-store.js'
import type { RelayConfig } from './config.js'
import type { RelayCredentialStore } from './credential-store.js'
import { HostSessionRegistry } from './host-session-registry.js'
import type { RelayTokenClaims } from './relay-token-verifier.js'
import { ProcessQueuedByteBudget } from './splice-forwarder.js'
const KEY = new TextEncoder().encode('0123456789abcdef0123456789abcdef')
const OTHER_KEY = new TextEncoder().encode('fedcba9876543210fedcba9876543210')
const CELL_ID = 'production-gce-c7'
const HOST = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' }
async function lease(
overrides: {
key?: Uint8Array
kid?: string | null
cellId?: string
assignmentEpoch?: number
sub?: string
relayHostId?: string
expiresAt?: string | number
} = {}
): Promise<string> {
const kid = overrides.kid === undefined ? assignmentLeaseKeyId(KEY) : overrides.kid
return await new SignJWT({
purpose: 'cell-assignment',
cellId: overrides.cellId ?? CELL_ID,
cellUrl: 'https://c7.relay.example.test',
assignmentEpoch: overrides.assignmentEpoch ?? 4,
relayHostId: overrides.relayHostId ?? HOST.relayHostId
})
.setProtectedHeader(kid === null ? { alg: 'HS256' } : { alg: 'HS256', kid })
.setIssuer('https://relay.example.test')
.setAudience(ASSIGNMENT_LEASE_AUDIENCE)
.setSubject(overrides.sub ?? HOST.userId)
.setIssuedAt()
.setExpirationTime(overrides.expiresAt ?? '5m')
.sign(overrides.key ?? KEY)
}
const classify = async (value: string | undefined, helloEpoch = 4) =>
await classifyAssignmentLease({
lease: value,
key: KEY,
cellId: CELL_ID,
...HOST,
helloEpoch
})
describe('assignment lease classification', () => {
it('names the key by a short hash of it', () => {
expect(assignmentLeaseKeyId(KEY)).toMatch(/^k1-[0-9a-f]{8}$/)
expect(assignmentLeaseKeyId(KEY)).toBe(
`k1-${createHash('sha256').update(KEY).digest('hex').slice(0, 8)}`
)
expect(assignmentLeaseKeyId(OTHER_KEY)).not.toBe(assignmentLeaseKeyId(KEY))
})
it('sorts every lease into one class', async () => {
expect(await classify(undefined)).toBe('absent')
expect(await classify(await lease())).toBe('valid')
// Directors from before the key id sign with none.
expect(await classify(await lease({ kid: null }))).toBe('valid')
expect(await classify(await lease({ expiresAt: Math.floor(Date.now() / 1000) - 10 }))).toBe(
'expired'
)
expect(await classify(await lease({ key: OTHER_KEY, kid: null }))).toBe('bad-signature')
expect(await classify(await lease({ kid: assignmentLeaseKeyId(OTHER_KEY) }))).toBe(
'bad-signature'
)
expect(await classify('not.a.jwt')).toBe('bad-signature')
expect(await classify('x'.repeat(5_000))).toBe('bad-signature')
expect(await classify(await lease({ sub: 'user-2' }))).toBe('wrong-host')
expect(await classify(await lease({ cellId: 'production-gce-c8' }))).toBe('wrong-cell')
expect(await classify(await lease({ assignmentEpoch: 3 }))).toBe('epoch-behind')
expect(await classify(await lease({ assignmentEpoch: 5 }))).toBe('epoch-ahead')
})
})
describe('assignment lease shadow', () => {
it('does nothing while the switch is off', () => {
const shadow = new AssignmentLeaseShadow({ enabled: () => false, key: KEY, cellId: CELL_ID })
expect(shadow.check({ lease: undefined, ...HOST, helloEpoch: 4 })).toBeNull()
})
it('counts each class against the database answer and logs one line per window', async () => {
let now = 0
const log = vi.fn()
const shadow = new AssignmentLeaseShadow({
enabled: () => true,
key: KEY,
cellId: CELL_ID,
now: () => now,
log
})
const valid = await lease()
const record = async (value: string | undefined, dbValid: boolean, dbReadMs: number) => {
const check = shadow.check({ lease: value, ...HOST, helloEpoch: 4 })
expect(check).not.toBeNull()
if (check) shadow.record(check, dbValid, dbReadMs)
await check
await Promise.resolve()
}
await record(valid, true, 2)
await record(valid, false, 4)
await record(undefined, true, 6)
expect(log).not.toHaveBeenCalled()
now = 60_000
await record(valid, true, 8)
expect(log).toHaveBeenCalledTimes(1)
expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toEqual({
event: ASSIGNMENT_LEASE_SHADOW_EVENT,
cellId: CELL_ID,
windowMs: 60_000,
classes: {
agree: 2,
disagree: 1,
absent: 1,
expired: 0,
badSignature: 0,
wrongHost: 0,
wrongCell: 0,
epochBehind: 0,
epochAhead: 0
},
dbRefused: 1,
dbReadMs: { samples: 4, p50: 4, p99: 8, max: 8 }
})
})
})
class FakeSocket extends EventEmitter {
readonly OPEN = 1
readonly CLOSED = 3
readyState = this.OPEN
readonly send = vi.fn()
readonly close = vi.fn((code?: number) => {
this.readyState = this.CLOSED
this.emit('close', code, Buffer.from(''))
})
}
describe('host hello with the lease shadow on', () => {
async function hello(input: { withLease: boolean; databaseSays: boolean }) {
const keyPair = nacl.box.keyPair()
const relayHostId = createHash('sha256')
.update(keyPair.publicKey)
.digest('base64url')
.slice(0, 16)
const identity: RelayTokenClaims = {
sub: HOST.userId,
prof: 'profile-1',
relayHostId,
purpose: 'host-control',
exp: 4_102_444_800
}
const recorded: string[] = []
const shadow = new AssignmentLeaseShadow({ enabled: () => true, key: KEY, cellId: CELL_ID })
const record = shadow.record.bind(shadow)
vi.spyOn(shadow, 'record').mockImplementation((check, dbValid, dbReadMs) => {
void check.then((leaseClass) => recorded.push(`${leaseClass}:${dbValid}`))
record(check, dbValid, dbReadMs)
})
const verifyCellAssignment = vi.fn(async () => input.databaseSays)
const registry = new HostSessionRegistry(
config(),
vi.fn(),
{} as RelayCredentialStore,
{ verifyCellAssignment } as unknown as RelayAssignmentStore,
new ProcessQueuedByteBudget(),
{ recordAuth: vi.fn(), recordForwardedBytes: vi.fn(), recordHttp: vi.fn(), recordReconnect: vi.fn(), recordSql: vi.fn() },
Date.now,
Math.random,
'incarnation-1',
shadow
)
const socket = new FakeSocket()
const hostLease = input.withLease ? await lease({ relayHostId }) : undefined
registry.acceptControl(socket as unknown as WebSocket, identity, undefined, undefined, hostLease)
socket.emit(
'message',
Buffer.from(
JSON.stringify({
type: 'host-hello',
v: 1,
relayHostId,
assignmentEpoch: 4,
hostPublicKeyB64: Buffer.from(keyPair.publicKey).toString('base64'),
appVersion: '1.4.200'
})
),
false
)
await vi.waitFor(() => expect(recorded).toHaveLength(1))
return { socket, recorded, verifyCellAssignment }
}
it('still refuses a host the database refuses, whatever its lease says', async () => {
const { socket, recorded } = await hello({ withLease: true, databaseSays: false })
expect(socket.close).toHaveBeenCalledWith(RELAY_CLOSE_CODE.WRONG_CELL, 'wrong assignment epoch')
expect(recorded).toEqual(['valid:false'])
})
it('still admits a host the database admits, with no lease at all', async () => {
const { socket, recorded, verifyCellAssignment } = await hello({
withLease: false,
databaseSays: true
})
expect(verifyCellAssignment).toHaveBeenCalledTimes(1)
expect(socket.close).not.toHaveBeenCalled()
expect(String(socket.send.mock.calls[0]?.[0])).toContain('"type":"host-challenge"')
expect(recorded).toEqual(['absent:true'])
})
})
function config(): RelayConfig {
return {
port: 8080,
publicUrl: 'https://c7.relay.example.test',
cellUrl: 'https://c7.relay.example.test',
authIssuer: 'https://auth.example.test',
authAudience: 'orca-relay',
jwksUrl: 'https://auth.example.test/jwks',
assignmentSigningKey: KEY,
role: 'cell',
cellId: CELL_ID,
cells: [],
adminAudience: 'https://relay.example.test/v1/admin/drain',
deployServiceAccount: 'deploy@example.test',
runtimeServiceAccount: 'relay-cell@example.test',
adminJwksUrl: 'https://auth.example.test/jwks',
databasePoolMax: 10,
publicAssignmentsEnabled: true,
publicAssignmentConcurrency: 2,
publicAssignmentQueueMax: 128,
publicAssignmentWaitMs: 4_000,
publicResolveConcurrency: 1,
publicResolveWaitMs: 5_000,
publicAssignmentRetryAfterSeconds: 5,
dataDir: './data'
}
}
+63
View File
@@ -0,0 +1,63 @@
import { createHash } from 'node:crypto'
import { decodeProtectedHeader, errors, jwtVerify } from 'jose'
import { z } from 'zod'
export const ASSIGNMENT_LEASE_AUDIENCE = 'orca-relay-cell'
const MAX_LEASE_LENGTH = 4 * 1024
// Names the signing key so a later rotation can verify both keys by id. Not a secret: it is a
// truncated hash of one.
export function assignmentLeaseKeyId(key: Uint8Array): string {
return `k1-${createHash('sha256').update(key).digest('hex').slice(0, 8)}`
}
export type AssignmentLeaseClass =
| 'valid'
| 'absent'
| 'expired'
| 'bad-signature'
| 'wrong-host'
| 'wrong-cell'
| 'epoch-behind'
| 'epoch-ahead'
const LeaseClaimsSchema = z.object({
purpose: z.literal('cell-assignment'),
sub: z.string(),
cellId: z.string(),
assignmentEpoch: z.number().int(),
relayHostId: z.string()
})
// Classifies the lease a host echoed against the hello it sent. Never throws.
export async function classifyAssignmentLease(input: {
lease: string | undefined
key: Uint8Array
cellId: string
userId: string
relayHostId: string
helloEpoch: number
}): Promise<AssignmentLeaseClass> {
if (!input.lease) return 'absent'
if (input.lease.length > MAX_LEASE_LENGTH) return 'bad-signature'
try {
// Leases from directors that predate the key id carry none; the one key verifies them.
const kid = decodeProtectedHeader(input.lease).kid
if (kid !== undefined && kid !== assignmentLeaseKeyId(input.key)) return 'bad-signature'
const { payload } = await jwtVerify(input.lease, input.key, {
audience: ASSIGNMENT_LEASE_AUDIENCE,
algorithms: ['HS256']
})
const claims = LeaseClaimsSchema.safeParse(payload)
if (!claims.success) return 'bad-signature'
if (claims.data.sub !== input.userId || claims.data.relayHostId !== input.relayHostId) {
return 'wrong-host'
}
if (claims.data.cellId !== input.cellId) return 'wrong-cell'
if (claims.data.assignmentEpoch < input.helloEpoch) return 'epoch-behind'
if (claims.data.assignmentEpoch > input.helloEpoch) return 'epoch-ahead'
return 'valid'
} catch (error) {
return error instanceof errors.JWTExpired ? 'expired' : 'bad-signature'
}
}
@@ -0,0 +1,387 @@
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { ASSIGNMENT_LIMITS } from '@orca-cloud/relay-contract'
import ts from 'typescript'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { RelayAssignmentStore, RelayCellInReserveModeError } from './assignment-store.js'
import type { RelayCellConfig } from './config.js'
import { openInMemoryRelayDatabase, type RelayDatabase } from './database.js'
// Step 5 census (design §6.3): a reserve-mode cell keeps its hosts in memory, so Postgres's
// per-host state for it is stale. Every store method is classified below; a new one fails
// this test until someone decides how it treats a reserve-mode cell.
const RESERVE_MODE_CENSUS: Record<string, string> = {
// One host's connect, resolve or lease: a reserve-mode cell answers these from memory, and
// its ledger row is written after the fact.
acquireActivity: 'connect-path',
activateControl: 'connect-path',
// A flip back's re-registration: the cell is leaving reserve mode, so its rows are rebuilt.
activateControlDeferringCell: 'connect-path',
assign: 'connect-path',
changeActivity: 'connect-path',
exchangeRegionCorrection: 'connect-path',
hostWhereabouts: 'connect-path',
markMigrationTargetRegistered: 'connect-path',
releaseActivity: 'connect-path',
renewControlActivities: 'connect-path',
renewControlActivity: 'connect-path',
resolve: 'connect-path',
verifyCellAssignment: 'connect-path',
// Sweeps and per-cell host readers: skip reserve-mode cells, or refuse them.
cellDeploymentStatus: 'refuses-reserve-cells',
cellEvacuationCapacity: 'refuses-reserve-cells',
cellEvacuationStatus: 'refuses-reserve-cells',
evacuateDeadCells: 'skips-reserve-cells',
prepareCellDrainAttempt: 'refuses-reserve-cells',
rebalanceDormant: 'refuses-reserve-cells',
releaseExpiredActivity: 'skips-reserve-cells',
releaseExpiredActivityLeases: 'skips-reserve-cells',
selectIdleRegionalRehomeCandidates: 'skips-reserve-cells',
startActiveCellEvacuations: 'refuses-reserve-cells',
startEvacuation: 'refuses-reserve-cells',
// Steps of a drain, migration or rehome that only exist once a refused start above began one.
abortExpiredEvacuations: 'follows-a-refused-start',
abortExpiredRegionalRehomes: 'follows-a-refused-start',
abortUnarrivedRegionalRehomes: 'follows-a-refused-start',
beginCellDrainSend: 'follows-a-refused-start',
commitIdleRegionalRehome: 'follows-a-refused-start',
// A reserve source keeps no activity rows, so its "no source units" check proves nothing.
completeEvacuation: 'refuses-reserve-cells',
completeEvacuationFromDeadSource: 'refuses-reserve-cells',
completeReadyEvacuations: 'follows-a-refused-start',
completeReadyRegionalRehomes: 'follows-a-refused-start',
prepareCellDrainRecovery: 'follows-a-refused-start',
proveCellDrainNotDelivered: 'follows-a-refused-start',
reapRegionalRehomeAttempts: 'follows-a-refused-start',
reconcileIdleRegionalRehome: 'follows-a-refused-start',
recordCellDrainApplicationReceipt: 'follows-a-refused-start',
refreshRegionalRehomeLeases: 'follows-a-refused-start',
supersedeRegisteredCellEvacuations: 'follows-a-refused-start',
supersedeRegisteredEvacuation: 'follows-a-refused-start',
// Cell-level state, fences and controls: no per-host rows.
abortCellFenceAttempt: 'cell-level',
addMigrationCells: 'cell-level',
adoptLegacyCellFence: 'cell-level',
applyCellAdmissionSelector: 'cell-level',
applyRegionalRehomeControl: 'cell-level',
cellAdmitMode: 'cell-level',
cellHeartbeatFresh: 'cell-level',
recordCellAdmitEffective: 'cell-level',
commitCellReservationDelta: 'cell-level',
attestCellFence: 'cell-level',
attestCellFenceAttempt: 'cell-level',
bindCellFencePlanGeneration: 'cell-level',
cellFenceAttempt: 'cell-level',
commitLegacyCellFenceAdoption: 'cell-level',
configureCell: 'cell-level',
disableRegionalRehomeControl: 'cell-level',
inspectCellAdmissionSelector: 'cell-level',
inspectRegionalRehomeControl: 'cell-level',
prepareCellFenceAttempt: 'cell-level',
previewRegionCorrection: 'cell-level',
previewRegionalRehomeEligibility: 'cell-level',
pruneReleasedControlReservations: 'cell-level',
reconcileCells: 'cell-level',
reconcileCellsAtStartup: 'cell-level',
reserveModeCells: 'cell-level',
recordCellFenceOperation: 'cell-level',
recordCellHeartbeat: 'cell-level',
recordCellRegionalRehomeStatus: 'cell-level',
regionCatalog: 'cell-level',
regionCorrectionOutcomes: 'cell-level',
regionalRehomeFleetSafety: 'cell-level',
releaseExpiredRegionPreferences: 'cell-level',
seatFeedCells: 'cell-level',
setCellAdmissionState: 'cell-level',
setCellAdmitMode: 'cell-level',
setCellEnabled: 'cell-level',
startCellFenceApply: 'cell-level'
}
const CELLS: RelayCellConfig[] = [
{ id: 'cell-a', url: 'https://relay-a.example.com', capacityRequests: 10 },
{ id: 'cell-b', url: 'https://relay-b.example.com', capacityRequests: 10 }
]
describe('reserve-mode census', () => {
let database: RelayDatabase | undefined
afterEach(async () => {
await database?.close()
database = undefined
})
it('classifies every public store method for reserve-mode cells', () => {
const methods = publicStoreMethods()
expect(methods.filter((name) => !(name in RESERVE_MODE_CENSUS))).toEqual([])
expect(Object.keys(RESERVE_MODE_CENSUS).filter((name) => !methods.includes(name))).toEqual([])
})
async function setup(now: () => number, reserve: string[] = []) {
database = await openInMemoryRelayDatabase()
const store = new RelayAssignmentStore(database, now)
await store.reconcileCells(CELLS)
await setReserve(store, reserve)
return store
}
async function setReserve(store: RelayAssignmentStore, reserve: string[]) {
for (const cell of CELLS) {
await store.setCellAdmitMode(cell.id, reserve.includes(cell.id) ? 'reserve' : 'db')
}
}
// A store that has never read the set: the table is unreadable.
async function unknownSet(now: () => number) {
const unknown = new RelayAssignmentStore(database!, now)
await database!.query('ALTER TABLE relay_cell_admit_modes RENAME TO relay_cell_admit_modes_gone')
return {
unknown,
restore: async () =>
await database!.query('ALTER TABLE relay_cell_admit_modes_gone RENAME TO relay_cell_admit_modes')
}
}
it('lets no expiry sweep touch a reserve-mode cell, and runs none while the set is unknown', async () => {
let now = 1_000
const store = await setup(() => now)
const onA = { userId: 'user-a', relayHostId: 'host000000000001' }
expect((await store.assign(onA)).cellId).toBe('cell-a')
await store.acquireActivity(onA, { activityId: 'invite:one', kind: 'invite', cellId: 'cell-a' })
await setReserve(store, ['cell-a'])
now += ASSIGNMENT_LIMITS.activityLeaseMs + 1
expect(await store.releaseExpiredActivityLeases()).toBe(0)
expect(await store.releaseExpiredActivity()).toBe(0)
const { unknown, restore } = await unknownSet(() => now)
expect(await unknown.reserveModeCells()).toBeNull()
expect(await unknown.releaseExpiredActivityLeases()).toBe(0)
expect(await unknown.evacuateDeadCells()).toBe(0)
await restore()
await setReserve(store, [])
expect(await store.releaseExpiredActivityLeases()).toBeGreaterThan(0)
})
it('logs an unreadable reserve-mode set at most once a minute', async () => {
let now = 1_000
await setup(() => now)
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const clock = vi.spyOn(performance, 'now')
let at = 1_000_000
clock.mockImplementation(() => at)
const { unknown, restore } = await unknownSet(() => now)
try {
for (let read = 0; read < 3; read += 1) {
expect(await unknown.reserveModeCells()).toBeNull()
at += 10_000
}
at += 60_000
expect(await unknown.reserveModeCells()).toBeNull()
const lines = warn.mock.calls.filter(([line]) => String(line).includes('orca_relay_reserve_mode_read_failed'))
expect(lines).toHaveLength(2)
} finally {
await restore()
clock.mockRestore()
warn.mockRestore()
}
})
it('tells break glass whether a cell is still heartbeating', async () => {
let now = 1_000
const store = await setup(() => now)
expect(await store.cellHeartbeatFresh('cell-a')).toBe(false)
await store.reconcileCells([{ ...CELLS[0]!, connectionHardCap: 600, connectionUnobservedBound: 50 }, CELLS[1]!])
await store.recordCellHeartbeat({
cellId: 'cell-a',
cellUrl: CELLS[0]!.url,
cellIncarnation: '11111111-1111-4111-8111-111111111111',
startedAt: 50,
ready: false,
observedRequests: 0,
totalConnections: 0,
inFlightConnections: 0,
reservedConnectionUnits: 0,
enforcedConnectionUnits: 0,
connectionHardCap: 600,
connectionUnobservedBound: 50
})
// Not ready is still alive: break glass is for a cell that has gone silent.
expect(await store.cellHeartbeatFresh('cell-a')).toBe(true)
now += 45_001
expect(await store.cellHeartbeatFresh('cell-a')).toBe(false)
})
it('keeps an idle host sticky on a reserve-mode cell where today it would be re-placed', async () => {
let now = 1_000
const store = await setup(() => now)
const identity = { userId: 'user-a', relayHostId: 'host000000000001' }
const first = await store.assign(identity)
await store.changeActivity(identity, 'control', -1)
now += ASSIGNMENT_LIMITS.activityLeaseMs + ASSIGNMENT_LIMITS.dormantTtlMs + 1
await setReserve(store, [first.cellId])
expect(await store.assign(identity)).toMatchObject({
cellId: first.cellId,
assignmentEpoch: first.assignmentEpoch
})
// Not knowing which cells are switched on is treated the same way.
const { unknown, restore } = await unknownSet(() => now)
expect((await unknown.assign(identity)).assignmentEpoch).toBe(first.assignmentEpoch)
await restore()
})
// Only the first reserve-set read is held; it is released (or failed) by the test.
function holdFirstRead(options: { fail?: boolean } = {}) {
const query = database!.query.bind(database!)
let release!: () => void
const released = new Promise<void>((resolve) => (release = resolve))
const counter = { reads: 0 }
database!.query = async (sql, params) => {
if (sql.includes('FROM relay_cell_admit_modes m')) {
counter.reads += 1
if (counter.reads === 1) {
await released
if (options.fail) throw new Error('canceling statement due to statement timeout')
}
}
return await query(sql, params)
}
return { counter, release, restore: () => (database!.query = query) }
}
it('reads afresh after an admit-mode write instead of joining a read begun before it', async () => {
const store = await setup(() => 1_000)
const held = holdFirstRead()
try {
const before = store.reserveModeCells()
await store.setCellAdmitMode('cell-a', 'reserve')
const after = await Promise.race([
store.reserveModeCells(),
new Promise<'joined'>((resolve) => setTimeout(() => resolve('joined'), 500))
])
expect(after === 'joined' ? after : [...(after ?? [])]).toEqual(['cell-a'])
held.release()
// The pre-write read lands after, and is not kept.
await before
expect([...((await store.reserveModeCells()) ?? [])]).toEqual(['cell-a'])
} finally {
held.release()
held.restore()
}
})
it('does not back off for a pre-write read that fails after the write', async () => {
const store = await setup(() => 1_000)
const held = holdFirstRead({ fail: true })
try {
const before = store.reserveModeCells()
await store.setCellAdmitMode('cell-a', 'reserve')
const reads = held.counter.reads
held.release()
await before
// A write cleared the cache: the next caller reads now, not after a 5 s backoff.
expect([...((await store.reserveModeCells()) ?? [])]).toEqual(['cell-a'])
expect(held.counter.reads).toBeGreaterThan(reads)
} finally {
held.release()
held.restore()
}
})
// A table unreadable for over a minute leaves a stale set, not none: a host on a cell last
// known to be in database mode is placed as today, not pinned as if its cell might be reserve.
it('checks pins and dormancy against the last-known set once the read goes stale', async () => {
let now = 1_000
const clock = vi.spyOn(performance, 'now')
let at = 5_000_000
clock.mockImplementation(() => at)
const store = await setup(() => now)
try {
const identity = { userId: 'user-a', relayHostId: 'host000000000001' }
const first = await store.assign(identity)
await store.changeActivity(identity, 'control', -1)
now += ASSIGNMENT_LIMITS.activityLeaseMs + ASSIGNMENT_LIMITS.dormantTtlMs + 1
expect([...((await store.reserveModeCells()) ?? [])]).toEqual([])
await database!.query('ALTER TABLE relay_cell_admit_modes RENAME TO relay_cell_admit_modes_gone')
at += 61_000
expect(await store.reserveModeCells()).toBeNull()
expect((await store.assign(identity)).assignmentEpoch).toBeGreaterThan(first.assignmentEpoch)
} finally {
await database!.query('ALTER TABLE relay_cell_admit_modes_gone RENAME TO relay_cell_admit_modes').catch(() => undefined)
clock.mockRestore()
}
})
it('never places a fresh host on a reserve-mode cell through the database', async () => {
const store = await setup(() => 1_000, ['cell-a'])
for (let index = 0; index < 4; index += 1) {
const placed = await store.assign({
userId: `user-${index}`,
relayHostId: `host${String(index).padStart(12, '0')}`
})
expect(placed.cellId).toBe('cell-b')
}
})
it('refuses drains, evacuations and status reads that would treat a reserve-mode cell as empty', async () => {
const store = await setup(() => 1_000, ['cell-a'])
await expect(store.cellDeploymentStatus('cell-a')).rejects.toBeInstanceOf(
RelayCellInReserveModeError
)
await expect(store.cellEvacuationCapacity('cell-a', 'cell-b')).rejects.toThrow(
'cell_in_reserve_mode'
)
await expect(store.startActiveCellEvacuations('cell-b', 'cell-a', 10)).rejects.toThrow(
'cell_in_reserve_mode'
)
await expect(
store.prepareCellDrainAttempt({
attemptId: '22222222-2222-4222-8222-222222222222',
cellId: 'cell-a',
cellIncarnation: '11111111-1111-4111-8111-111111111111',
traceValue: 'trace',
plannedGraceMs: 1_000
})
).rejects.toThrow('cell_in_reserve_mode')
expect((await store.cellDeploymentStatus('cell-b')).cellId).toBe('cell-b')
// Unknown refuses too, with a retryable error.
const { unknown, restore } = await unknownSet(() => 1_000)
await expect(unknown.cellDeploymentStatus('cell-b')).rejects.toThrow('reserve_mode_unknown')
await restore()
})
it('refuses the flip to reserve while a migration names the cell', async () => {
const store = await setup(() => 1_000)
await database!.query(
`INSERT INTO relay_assignment_migrations
(user_id, relay_host_id, source_cell_id, target_cell_id, previous_epoch, assignment_epoch,
source_request_units, target_reserved_units, expires_at, created_at, updated_at)
VALUES ('user-a', 'host000000000001', 'cell-b', 'cell-a', 1, 2, 0, 1, 9999, 1000, 1000)`
)
await expect(store.setCellAdmitMode('cell-a', 'reserve')).rejects.toThrow('cell_has_open_migration')
expect(await store.cellAdmitMode('cell-a')).toMatchObject({ admitMode: 'db' })
await store.setCellAdmitMode('cell-a', 'db')
await database!.query(`UPDATE relay_assignment_migrations SET aborted_at = 1001`)
await store.setCellAdmitMode('cell-a', 'reserve')
expect(await store.cellAdmitMode('cell-a')).toMatchObject({ admitMode: 'reserve', updatedAt: 1_000 })
})
})
function publicStoreMethods(): string[] {
const path = fileURLToPath(new URL('./assignment-store.ts', import.meta.url))
const source = ts.createSourceFile(path, readFileSync(path, 'utf8'), ts.ScriptTarget.ES2022, true)
const names = new Set<string>()
source.forEachChild((node) => {
if (!ts.isClassDeclaration(node) || node.name?.text !== 'RelayAssignmentStore') return
for (const member of node.members) {
if (!ts.isMethodDeclaration(member) || !ts.isIdentifier(member.name)) continue
const hidden = ts
.getModifiers(member)
?.some(
(modifier) =>
modifier.kind === ts.SyntaxKind.PrivateKeyword ||
modifier.kind === ts.SyntaxKind.ProtectedKeyword
)
if (!hidden) names.add(member.name.text)
}
})
return [...names].sort()
}
@@ -0,0 +1,251 @@
import { randomUUID } from 'node:crypto'
import { ASSIGNMENT_LIMITS } from '@orca-cloud/relay-contract'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { CELL_ADMIT_EFFECTIVE_FRESH_MS, RelayAssignmentStore } from './assignment-store.js'
import type { RelayCellConfig } from './config.js'
import { openRelayDatabase, type RelayDatabase } from './database.js'
// The step-5 census on real PostgreSQL: every new statement (the admit-mode table, the
// open-flow check under FOR UPDATE, the live-reserve pin) must run there, not only on SQLite.
const databaseUrl = process.env.ORCA_RELAY_TEST_POSTGRES_URL
const describePostgres = databaseUrl ? describe : describe.skip
const run = randomUUID().slice(0, 8)
const cellA: RelayCellConfig = { id: `census-pg-a-${run}`, url: `https://census-a-${run}.example.com`, capacityRequests: 10, connectionHardCap: 600, connectionUnobservedBound: 50 }
const cellB: RelayCellConfig = { id: `census-pg-b-${run}`, url: `https://census-b-${run}.example.com`, capacityRequests: 10, connectionHardCap: 600, connectionUnobservedBound: 50 }
const USER = `census-pg-${run}`
describePostgres('reserve-mode census on PostgreSQL', () => {
let database: RelayDatabase
beforeAll(async () => {
database = await openRelayDatabase({ databaseUrl, dataDir: '/tmp' })
})
afterAll(async () => {
for (const table of ['relay_assignment_activity_leases', 'relay_assignment_migrations', 'relay_assignments']) {
await database.query(`DELETE FROM ${table} WHERE user_id = ?`, [USER])
}
for (const cell of [cellA, cellB]) {
for (const table of ['relay_cell_admit_effective', 'relay_cell_admit_modes', 'relay_cell_admission', 'relay_cell_regions', 'relay_cell_runtime', 'relay_cells']) {
await database.query(`DELETE FROM ${table} WHERE cell_id = ?`, [cell.id])
}
}
await database.close()
})
const heartbeat = async (store: RelayAssignmentStore, cell: RelayCellConfig, ready = true) =>
await store.recordCellHeartbeat({
cellId: cell.id,
cellUrl: cell.url,
cellIncarnation: '11111111-1111-4111-8111-111111111111',
startedAt: 50,
ready,
observedRequests: 0,
totalConnections: 0,
inFlightConnections: 0,
reservedConnectionUnits: 0,
enforcedConnectionUnits: 0,
connectionHardCap: 600,
connectionUnobservedBound: 50
})
it('flips a cell to reserve and back, and refuses reserve while a migration names it', async () => {
const store = new RelayAssignmentStore(database, () => 1_000)
await store.reconcileCells([cellA, cellB], false)
await store.setCellAdmitMode(cellA.id, 'reserve')
expect(await store.cellAdmitMode(cellA.id)).toEqual({ admitMode: 'reserve', updatedAt: 1_000 })
expect([...((await store.reserveModeCells()) ?? [])]).toContain(cellA.id)
await store.setCellAdmitMode(cellA.id, 'db')
expect(await store.cellAdmitMode(cellA.id)).toMatchObject({ admitMode: 'db' })
await database.query(
`INSERT INTO relay_assignment_migrations
(user_id, relay_host_id, source_cell_id, target_cell_id, previous_epoch, assignment_epoch,
source_request_units, target_reserved_units, expires_at, created_at, updated_at)
VALUES (?, 'host000000000009', ?, ?, 1, 2, 0, 1, 9999, 1000, 1000)`,
[USER, cellB.id, cellA.id]
)
await expect(store.setCellAdmitMode(cellA.id, 'reserve')).rejects.toThrow('cell_has_open_migration')
await database.query(`DELETE FROM relay_assignment_migrations WHERE user_id = ?`, [USER])
await store.setCellAdmitMode(cellA.id, 'reserve')
await store.setCellAdmitMode(cellA.id, 'db')
})
it('pins a host on a live reserve cell without writing its rows, and re-places it once that cell is dead', async () => {
let now = 100_000
const store = new RelayAssignmentStore(database, () => now, { requireLiveCells: true, heartbeatTtlMs: 45_000 })
await store.reconcileCells([cellA, cellB], false)
await heartbeat(store, cellA)
await heartbeat(store, cellB)
expect(await store.cellHeartbeatFresh(cellA.id)).toBe(true)
const identity = { userId: USER, relayHostId: 'host000000000001' }
await database.query(
`INSERT INTO relay_assignments
(user_id, relay_host_id, cell_id, assignment_epoch, lease_expires_at, last_activity_at,
reserved_controls, reserved_splices, reserved_invites, pending_installs,
pending_confirmations, migration_leases)
VALUES (?, ?, ?, 4, ?, ?, 0, 0, 0, 0, 0, 0)`,
[USER, identity.relayHostId, cellA.id, now, now]
)
await store.setCellAdmitMode(cellA.id, 'reserve')
now += ASSIGNMENT_LIMITS.activityLeaseMs + ASSIGNMENT_LIMITS.dormantTtlMs + 1
await heartbeat(store, cellA)
await heartbeat(store, cellB)
const reservedBefore = await database.query(`SELECT reserved_requests FROM relay_cells WHERE cell_id = ?`, [cellA.id])
expect(await store.assign(identity)).toMatchObject({ cellId: cellA.id, assignmentEpoch: 4 })
expect(await database.query(`SELECT reserved_requests FROM relay_cells WHERE cell_id = ?`, [cellA.id])).toEqual(
reservedBefore
)
expect(
await database.query(`SELECT activity_id FROM relay_assignment_activity_leases WHERE user_id = ?`, [USER])
).toEqual([])
// cellA stops heartbeating: a dead cell holds no duplicate, so the host is re-placed.
now += 60_000
await heartbeat(store, cellB)
expect(await store.cellHeartbeatFresh(cellA.id)).toBe(false)
expect((await store.assign(identity)).cellId).toBe(cellB.id)
await store.setCellAdmitMode(cellA.id, 'db')
})
// A database brownout drops ready, not the socket: a host on a reserve cell that still
// heartbeats is not re-placed (that would seat it twice); its home just reads unavailable.
it('does not re-place a host off a reserve cell that heartbeats while not ready', async () => {
let now = 400_000
const store = new RelayAssignmentStore(database, () => now, { requireLiveCells: true, heartbeatTtlMs: 45_000 })
await store.reconcileCells([cellA, cellB], false)
const identity = { userId: USER, relayHostId: 'host000000000005' }
await database.query(
`INSERT INTO relay_assignments
(user_id, relay_host_id, cell_id, assignment_epoch, lease_expires_at, last_activity_at,
reserved_controls, reserved_splices, reserved_invites, pending_installs,
pending_confirmations, migration_leases)
VALUES (?, ?, ?, 7, ?, ?, 0, 0, 0, 0, 0, 0)`,
[USER, identity.relayHostId, cellA.id, now, now]
)
await store.setCellAdmitMode(cellA.id, 'reserve')
now += ASSIGNMENT_LIMITS.activityLeaseMs + ASSIGNMENT_LIMITS.dormantTtlMs + 1
await heartbeat(store, cellA, false)
await heartbeat(store, cellB)
await expect(store.assign(identity)).rejects.toThrow('relay_home_cell_unavailable')
expect(
await database.query(`SELECT cell_id FROM relay_assignments WHERE user_id = ? AND relay_host_id = ?`, [
USER,
identity.relayHostId
])
).toEqual([{ cell_id: cellA.id }])
await store.setCellAdmitMode(cellA.id, 'db')
})
// A fleet-wide dead-man trip leaves every cell reserve in PG, and no director writes db back.
// The database path places on a reserve cell only by that cell's own fresh row saying db.
it('places a fresh host on a reserve cell only while its own current-process row says db', async () => {
let now = 200_000
const fresh = () => new RelayAssignmentStore(database, () => now)
const setup = fresh()
await setup.reconcileCells([cellA, cellB], false)
await heartbeat(setup, cellA)
await heartbeat(setup, cellB)
await setup.setCellAdmitMode(cellA.id, 'reserve')
await setup.setCellAdmitMode(cellB.id, 'reserve')
// An older cell image writes no row: never admitted.
await expect(fresh().assign({ userId: USER, relayHostId: 'host000000000002' })).rejects.toThrow(
'relay_capacity_exhausted'
)
// A row from another process (a restarted cell's predecessor) does not count either.
await setup.recordCellAdmitEffective({ cellId: cellB.id, cellIncarnation: '22222222-2222-4222-8222-222222222222', mode: 'db' })
await setup.recordCellAdmitEffective({ cellId: cellA.id, cellIncarnation: '11111111-1111-4111-8111-111111111111', mode: 'reserve' })
await expect(fresh().assign({ userId: USER, relayHostId: 'host000000000002' })).rejects.toThrow(
'relay_capacity_exhausted'
)
await setup.recordCellAdmitEffective({ cellId: cellA.id, cellIncarnation: '11111111-1111-4111-8111-111111111111', mode: 'db' })
const store = fresh()
expect((await store.assign({ userId: USER, relayHostId: 'host000000000003' })).cellId).toBe(cellA.id)
// Census sweeps still treat it as reserve.
expect([...((await store.reserveModeCells()) ?? [])]).toEqual(expect.arrayContaining([cellA.id, cellB.id]))
// Three missed 15 s refreshes and the row counts for nothing.
now += CELL_ADMIT_EFFECTIVE_FRESH_MS + 1
await heartbeat(setup, cellA)
await expect(fresh().assign({ userId: USER, relayHostId: 'host000000000006' })).rejects.toThrow(
'relay_capacity_exhausted'
)
await setup.setCellAdmitMode(cellA.id, 'db')
await setup.setCellAdmitMode(cellB.id, 'db')
})
// A timed-out read inside BEGIN aborts the transaction (25P02 on the next statement), so the
// reserve set is read before BEGIN and the transaction answers from that read.
it('assigns while the admit-mode read times out (57014), without aborting the transaction', async () => {
const timed = await openRelayDatabase({ databaseUrl, dataDir: '/tmp', statementTimeoutMs: 300 })
const holder = await openRelayDatabase({ databaseUrl, dataDir: '/tmp' })
let release!: () => void
const released = new Promise<void>((resolve) => (release = resolve))
let held!: () => void
const holding = new Promise<void>((resolve) => (held = resolve))
const lock = holder.transaction(async (transaction) => {
await transaction.query(`LOCK TABLE relay_cell_admit_modes IN ACCESS EXCLUSIVE MODE`)
held()
await released
})
try {
await holding
const store = new RelayAssignmentStore(timed, () => 300_000)
await store.reconcileCells([cellA, cellB], false)
await heartbeat(store, cellA)
await heartbeat(store, cellB)
expect(await store.reserveModeCells()).toBeNull()
const placed = await store.assign({ userId: USER, relayHostId: 'host000000000004' })
expect([cellA.id, cellB.id]).toContain(placed.cellId)
} finally {
release()
await lock
await timed.close()
await holder.close()
}
}, 20_000)
// A stall must not double connection demand: concurrent assigns share one reserve-set read,
// and after it fails the last answer stands for the backoff without another query.
it('reads the reserve set once for concurrent assigns during a stall, and not again in the backoff', async () => {
const timed = await openRelayDatabase({ databaseUrl, dataDir: '/tmp', statementTimeoutMs: 300 })
const holder = await openRelayDatabase({ databaseUrl, dataDir: '/tmp' })
let release!: () => void
const released = new Promise<void>((resolve) => (release = resolve))
let held!: () => void
const holding = new Promise<void>((resolve) => (held = resolve))
const lock = holder.transaction(async (transaction) => {
await transaction.query(`LOCK TABLE relay_cell_admit_modes IN ACCESS EXCLUSIVE MODE`)
held()
await released
})
try {
await holding
const store = new RelayAssignmentStore(timed, () => 500_000)
await store.reconcileCells([cellA, cellB], false)
await heartbeat(store, cellA)
await heartbeat(store, cellB)
const query = timed.query.bind(timed)
let reads = 0
timed.query = async (sql, params) => {
if (sql.includes('FROM relay_cell_admit_modes m')) reads += 1
return await query(sql, params)
}
const placed = await Promise.all(
Array.from({ length: 6 }, async (_, index) =>
await store.assign({ userId: USER, relayHostId: `hoststall0000${String(index).padStart(3, '0')}` })
)
)
expect(placed).toHaveLength(6)
expect(reads).toBe(1)
await store.assign({ userId: USER, relayHostId: 'hoststall0000100' })
expect(await store.reserveModeCells()).toBeNull()
expect(reads).toBe(1)
} finally {
release()
await lock
await timed.close()
await holder.close()
}
}, 20_000)
})
@@ -16,6 +16,8 @@ class LockOrderDatabase implements RelayDatabase {
) {}
async query(sql: string): Promise<SqlRow[]> {
// No cell is in reserve mode.
if (sql.includes('FROM relay_cell_admit_modes')) return []
if (sql.includes('SELECT user_id, relay_host_id, activity_id')) {
return this.cleanupCandidate
? [
@@ -149,7 +151,8 @@ class ReassignmentLockOrderDatabase implements RelayDatabase {
class AggregateCleanupDatabase implements RelayDatabase {
failIfUnavailable: boolean | null = null
async query(): Promise<SqlRow[]> {
async query(sql: string): Promise<SqlRow[]> {
if (sql.includes('FROM relay_cell_admit_modes')) return []
return [{ changes: 1 }]
}
+516 -45
View File
@@ -1,5 +1,7 @@
import { createDrainMigrationRowLookup } from './drain-migration-row-lookup.js'
import { HeapWindowReaper } from './heap-window-reaper.js'
import { PlacementLoadBand } from './placement-load-band.js'
import type { SeatFeedCell } from './shadow-seat-directory.js'
import {
selectIdleRegionalRehomes,
type IdleRegionalRehomeCandidate,
@@ -72,6 +74,7 @@ import {
import type { RelayCellConfig } from './config.js'
import type { CellLockHoldSite } from './cell-inventory-hold-samples.js'
import { isPostgresPoolConnectFailure } from './postgres-pool-pressure.js'
import { isPostgresReadTimeout } from './relay-database-rejection-fence.js'
import type {
RelayDatabase,
RelayLockOptions,
@@ -130,11 +133,41 @@ type RelayAssignmentStoreOptions = {
regionalRehomeDirectorRegion?: RelayRegion
requireLiveCells?: boolean
heartbeatTtlMs?: number
// Absent keeps the strict least-loaded pick (tests that assert exact cells).
placementLoadBand?: PlacementLoadBand
recordControlRenewal?: (durationMs: number, outcome: ControlRenewalOutcome) => void
}
export type { ControlRenewalOutcome, ControlRenewalRequest }
// `cellId` null: the reserve-mode set could not be read, so no cell is known to be in db mode.
export class RelayCellInReserveModeError extends Error {
constructor(readonly cellId: string | null) {
super(cellId === null ? 'reserve_mode_unknown' : 'cell_in_reserve_mode')
}
}
// Step 5: the flag workflow records each cell's admitMode here (reserve before the cell's
// switch file, db only after the cell has re-registered its controls). Every per-host sweep
// and reader skips reserve cells, and does nothing while the set is unknown.
const RESERVE_MODE_CACHE_MS = 5_000
const RESERVE_MODE_STALE_MS = 60_000
// After a failed read, the last good one answers this long without a query: during a stall
// every assign would otherwise spend a read's timeout before its own transaction.
const RESERVE_MODE_FAILURE_BACKOFF_MS = 5_000
// A cell refreshes its admit-effective row every 15 s; three missed refreshes and it counts for nothing.
export const CELL_ADMIT_EFFECTIVE_FRESH_MS = 45_000
const RESERVE_MODE_FAILURE_LOG_MS = 60_000
type ControlActivationInput = {
cellId: string
assignmentEpoch: number
generation: number
idleRegionalRehome?: boolean
cellIncarnation?: string
connectionInclusionWatermark?: number
}
export type RelayAssignment = AssignmentIdentity & {
cellId: string
cellUrl: string
@@ -147,6 +180,20 @@ export type RelayAssignment = AssignmentIdentity & {
// a roll right now: its next assign re-places it rather than re-granting the pin.
export type ResolvedRelayAssignment = RelayAssignment & { homeCellRollIsolated?: true }
export type HostWhereaboutsRow = {
cellId: string
assignmentEpoch: number
leaseExpiresAt: number
openMigration: {
sourceCellId: string
targetCellId: string
previousEpoch: number
assignmentEpoch: number
expiresAt: number
targetRegisteredAt: number | null
} | null
}
export type RelayRegionCatalogEntry = {
region: RelayRegion
probeOrigins: string[]
@@ -551,6 +598,11 @@ const ABORTABLE_EXPIRED_MIGRATION = `(
OR ${ABANDONED_REGISTERED_MIGRATION}
)`
// `cell_id NOT IN (...)` for the reserve-mode cells, or nothing when there are none.
function excludeCellsSql(column: string, cellIds: readonly string[]): string {
return cellIds.length === 0 ? '' : ` AND ${column} NOT IN (${cellIds.map(() => '?').join(', ')})`
}
export class RelayAssignmentStore {
private readonly regionalRehomeCohortPercent: number
private readonly regionalRehomeDirectorRegion?: RelayRegion
@@ -565,6 +617,18 @@ export class RelayAssignmentStore {
{ failures: number; until: number }
>()
private readonly recordControlRenewal?: RelayAssignmentStoreOptions['recordControlRenewal']
private reserveModeRead: {
cells: ReadonlySet<string>
admitsDatabase: ReadonlySet<string>
readAt: number
} | null = null
private reserveModeFailureLoggedAt = Number.NEGATIVE_INFINITY
private reserveModeFailedAt = Number.NEGATIVE_INFINITY
// One read at a time: concurrent callers share it.
private reserveModeInFlight: Promise<void> | null = null
// Bumped by an admit-mode write, so a read begun before it cannot land after it.
private reserveModeGeneration = 0
private readonly placementLoadBand?: PlacementLoadBand
private readonly admissionSelector: RelayCellAdmissionSelector
private readonly migrationCellRegistrar: RelayMigrationCellRegistrar
private readonly activityQueue = new AssignmentIdentityQueue()
@@ -591,10 +655,210 @@ export class RelayAssignmentStore {
this.requireLiveCells = options.requireLiveCells ?? false
this.heartbeatTtlMs = options.heartbeatTtlMs ?? 45_000
this.recordControlRenewal = options.recordControlRenewal
this.placementLoadBand = options.placementLoadBand
this.admissionSelector = new RelayCellAdmissionSelector(database, now)
this.migrationCellRegistrar = new RelayMigrationCellRegistrar(database, now)
}
// Null when unreadable for a minute: callers then treat every cell as possibly reserve.
// Inside a transaction (any `database` but the pool) it answers from the last read and never
// runs a statement: a failed read there (57014) would abort the transaction, and the next
// statement's 25P02 is not retried. Callers read once before they begin.
async reserveModeCells(
database: Pick<RelayDatabase, 'query'> = this.database
): Promise<ReadonlySet<string> | null> {
const at = performance.now()
const cached = this.reserveModeRead
const answer = (): ReadonlySet<string> | null => {
const read = this.reserveModeRead
return read && performance.now() - read.readAt <= RESERVE_MODE_STALE_MS ? read.cells : null
}
if (
database !== this.database ||
(cached && at - cached.readAt < RESERVE_MODE_CACHE_MS) ||
at - this.reserveModeFailedAt < RESERVE_MODE_FAILURE_BACKOFF_MS
) {
return answer()
}
if (!this.reserveModeInFlight) {
const read: Promise<void> = this.readReserveModes(database).finally(() => {
if (this.reserveModeInFlight === read) this.reserveModeInFlight = null
})
this.reserveModeInFlight = read
}
await this.reserveModeInFlight
return answer()
}
private async readReserveModes(database: Pick<RelayDatabase, 'query'>): Promise<void> {
const at = performance.now()
const generation = this.reserveModeGeneration
try {
// With each reserve cell, whether its own current process says it admits through the
// database (a tripped dead-man): the one read the database path may place on it by.
const rows = await database.query(
`SELECT m.cell_id, e.cell_id AS admits_database
FROM relay_cell_admit_modes m
LEFT JOIN relay_cell_admit_effective e
ON e.cell_id = m.cell_id
AND e.mode = 'db'
AND e.updated_at > ?
AND e.cell_incarnation =
(SELECT r.cell_incarnation FROM relay_cell_runtime r WHERE r.cell_id = m.cell_id)
WHERE m.admit_mode <> 'db'`,
[this.now() - CELL_ADMIT_EFFECTIVE_FRESH_MS]
)
if (generation !== this.reserveModeGeneration) return
this.reserveModeRead = {
cells: new Set(rows.map((row) => text(row, 'cell_id'))),
admitsDatabase: new Set(rows.filter((row) => row.admits_database != null).map((row) => text(row, 'cell_id'))),
readAt: at
}
} catch (error) {
// A read begun before an admit-mode write says nothing about the table after it.
if (generation === this.reserveModeGeneration) this.reserveModeFailedAt = performance.now()
// Keep the last read until it is stale. Logged at most once a minute: while it fails,
// sweeps soon stop and admin operations are refused, so the cause must be on the record.
if (at - this.reserveModeFailureLoggedAt >= RESERVE_MODE_FAILURE_LOG_MS) {
this.reserveModeFailureLoggedAt = at
console.warn(
JSON.stringify({
event: 'orca_relay_reserve_mode_read_failed',
reason: error instanceof Error ? error.message : 'unknown',
lastReadAgeMs: this.reserveModeRead ? Math.round(at - this.reserveModeRead.readAt) : null
})
)
}
}
}
// Only from a recent read: a cell re-armed since may be booking from memory again.
private reserveCellAdmitsDatabase(cellId: string): boolean {
const read = this.reserveModeRead
return read !== null && performance.now() - read.readAt <= 2 * RESERVE_MODE_CACHE_MS && read.admitsDatabase.has(cellId)
}
// Written by the cell itself, best-effort, on every change of its raw mode and every 15 s.
async recordCellAdmitEffective(input: {
cellId: string
cellIncarnation: string
mode: 'db' | 'reserve'
}): Promise<void> {
await this.database.query(
`INSERT INTO relay_cell_admit_effective (cell_id, cell_incarnation, mode, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT (cell_id) DO UPDATE SET
cell_incarnation = excluded.cell_incarnation,
mode = excluded.mode,
updated_at = excluded.updated_at`,
[input.cellId, input.cellIncarnation, input.mode, this.now()]
)
}
// Break glass flips only a cell that has stopped heartbeating, ready or not.
async cellHeartbeatFresh(cellId: string): Promise<boolean> {
return await this.heartbeatFresh(this.database, cellId, this.now())
}
private async heartbeatFresh(database: Pick<RelayDatabase, 'query'>, cellId: string, now: number): Promise<boolean> {
const rows = await database.query(
`SELECT cell_id FROM relay_cell_runtime WHERE cell_id = ? AND last_heartbeat_at > ?`,
[cellId, now - this.heartbeatTtlMs]
)
return rows.length === 1
}
// A reserve cell still heartbeating holds its hosts in memory, ready or not (a database
// brownout drops ready): the same test as break glass, so neither re-places open sockets.
private async liveReserveCell(
cellId: string,
database: RelayDatabase,
now: number
): Promise<boolean> {
if (!(await this.mayBeReserveCell(cellId, database))) return false
return !this.requireLiveCells || (await this.heartbeatFresh(database, cellId, now))
}
// A stale set beats none: an unreadable table for a minute must not pin every host. Only a
// store that has never read the set treats every cell as possibly reserve.
private async mayBeReserveCell(
cellId: string,
database: Pick<RelayDatabase, 'query'> = this.database
): Promise<boolean> {
const cells = (await this.reserveModeCells(database)) ?? this.reserveModeRead?.cells ?? null
return cells === null || cells.has(cellId)
}
// Ops on a reserve cell would act on rows its hosts no longer keep; unknown refuses too.
private async refuseReserveCell(
cellId: string,
database: Pick<RelayDatabase, 'query'> = this.database
): Promise<void> {
const cells = await this.reserveModeCells(database)
if (cells === null) throw new RelayCellInReserveModeError(null)
if (cells.has(cellId)) throw new RelayCellInReserveModeError(cellId)
}
async cellAdmitMode(cellId: string): Promise<{ admitMode: 'db' | 'reserve'; updatedAt: number | null }> {
const row = (
await this.database.query(
`SELECT admit_mode, updated_at FROM relay_cell_admit_modes WHERE cell_id = ?`,
[cellId]
)
)[0]
return row
? { admitMode: text(row, 'admit_mode') === 'reserve' ? 'reserve' : 'db', updatedAt: integer(row, 'updated_at') }
: { admitMode: 'db', updatedAt: null }
}
// Reserve is refused while anything that moves this cell's rows is open: those flows read
// and write per-host rows a reserve cell no longer keeps.
async setCellAdmitMode(cellId: string, admitMode: 'db' | 'reserve'): Promise<void> {
const now = this.now()
await this.database.transaction(async (transaction) => {
if (admitMode === 'reserve') {
// One locked read per table: Postgres refuses FOR UPDATE on a UNION.
const openFlows: Array<[string, string, unknown[]]> = [
[
'migration',
`SELECT user_id FROM relay_assignment_migrations
WHERE (source_cell_id = ? OR target_cell_id = ?)
AND completed_at IS NULL AND aborted_at IS NULL LIMIT 1`,
[cellId, cellId]
],
[
'rehome',
`SELECT attempt_id FROM relay_region_rehome_attempts
WHERE (source_cell_id = ? OR target_cell_id = ?)
AND completed_at IS NULL AND aborted_at IS NULL LIMIT 1`,
[cellId, cellId]
],
[
'drain',
`SELECT attempt_id FROM relay_cell_drain_attempt_states
WHERE cell_id = ? AND state <> 'proven-not-delivered'
AND cell_incarnation = (SELECT cell_incarnation FROM relay_cell_runtime WHERE cell_id = ?)
LIMIT 1`,
[cellId, cellId]
]
]
for (const [kind, sql, params] of openFlows) {
if ((await transaction.queryLocked(sql, params))[0]) throw new Error(`cell_has_open_${kind}`)
}
}
await transaction.query(
`INSERT INTO relay_cell_admit_modes (cell_id, admit_mode, updated_at) VALUES (?, ?, ?)
ON CONFLICT (cell_id) DO UPDATE SET admit_mode = excluded.admit_mode, updated_at = excluded.updated_at`,
[cellId, admitMode, now]
)
})
this.reserveModeRead = null
this.reserveModeFailedAt = Number.NEGATIVE_INFINITY
this.reserveModeGeneration += 1
// The next caller reads afresh rather than joining a read from before this write.
this.reserveModeInFlight = null
}
async reconcileCells(cells: RelayCellConfig[], disableMissing = true): Promise<void> {
await this.reconcileCellsWithOptions(cells, disableMissing)
}
@@ -721,15 +985,26 @@ export class RelayAssignmentStore {
placementRegion: RelayRegion = preferredRegion ?? RELAY_DEFAULT_REGION,
// evacuateDeadCells re-enters placement from a sweep; it must not take the
// bounded wait, whose 55P03 would surface as a terminal sweep failure.
lockMode: CellInventoryLockMode = 'request'
lockMode: CellInventoryLockMode = 'request',
// Step 5: a fresh placement may first be booked on a reserve-mode cell, outside any
// transaction; and a database mint stays above every epoch the director's map knew.
step5: { epochFloor?: number; placeFresh?: () => Promise<RelayAssignment | null> } = {}
): Promise<RelayAssignment> {
const sticky = await this.assignStickyWithLockRetry(identity, lockMode, preferredRegion)
if (sticky) return sticky
const booked = await step5.placeFresh?.()
if (booked) return booked
// Only placement needs the global inventory critical section; queueing those
// attempts locally avoids turning true placement bursts into NOWAIT storms.
return await this.serializeAssignment(
async () =>
await this.assignWithLockRetry(identity, lockMode, preferredRegion, placementRegion)
await this.assignWithLockRetry(
identity,
lockMode,
preferredRegion,
placementRegion,
step5.epochFloor ?? 0
)
)
}
@@ -748,7 +1023,8 @@ export class RelayAssignmentStore {
identity: AssignmentIdentity,
lockMode: CellInventoryLockMode,
preferredRegion?: RelayRegion,
placementRegion: RelayRegion = preferredRegion ?? RELAY_DEFAULT_REGION
placementRegion: RelayRegion = preferredRegion ?? RELAY_DEFAULT_REGION,
epochFloor = 0
): Promise<RelayAssignment> {
const deadline = Date.now() + ASSIGNMENT_LOCK_RETRY_DEADLINE_MS
let inventoryScope: AssignmentInventoryScope = 'none'
@@ -759,7 +1035,8 @@ export class RelayAssignmentStore {
inventoryScope,
lockMode,
preferredRegion,
placementRegion
placementRegion,
epochFloor
)
} catch (error) {
if (error instanceof AssignmentInventoryScopeChanged) {
@@ -806,6 +1083,7 @@ export class RelayAssignmentStore {
preferredRegion?: RelayRegion
): Promise<RelayAssignment | null> {
const now = this.now()
await this.reserveModeCells()
return await this.database.transaction(async (transaction) => {
// Why: the retry exists to take a cell row before the assignment row, the
// order placement uses. It only ever needs the one cell this host is
@@ -841,7 +1119,13 @@ export class RelayAssignmentStore {
if (!existing) return null
const activityLeases = await this.lockAssignmentActivities(transaction, identity, true)
await this.recordRegionPreference(transaction, identity, preferredRegion, now)
if (mayNormallyReassign(activity(existing), now)) return null
// A live reserve cell's hosts hold no activity here, so idle is not evidence they left.
if (
!(await this.liveReserveCell(text(existing, 'cell_id'), transaction, now)) &&
mayNormallyReassign(activity(existing), now)
) {
return null
}
const pinnedAdmission =
text(existing, 'cell_id') === pinnedCellId
? unlockedAdmission
@@ -913,6 +1197,17 @@ export class RelayAssignmentStore {
) {
return null
}
const leaseExpiresAt = now + ASSIGNMENT_LIMITS.activityLeaseMs
// A reserve cell counts its own controls: the pin is returned with no row written,
// since the sweeps that would release those units skip this cell.
if (!hadControl && (await this.mayBeReserveCell(currentCellId, transaction))) {
return this.result(
identity,
existing,
cell(currentRow, await this.cellRegion(transaction, currentCellId)),
leaseExpiresAt
)
}
if (
!hadControl &&
!(await this.cellHasConnectionHeadroom(transaction, currentCellId))
@@ -921,7 +1216,6 @@ export class RelayAssignmentStore {
throw new Error('relay_connection_headroom_exhausted')
}
const leaseExpiresAt = now + ASSIGNMENT_LIMITS.activityLeaseMs
if (hadControl) {
await this.touchAssignment(transaction, identity, leaseExpiresAt, now)
} else {
@@ -1008,13 +1302,15 @@ export class RelayAssignmentStore {
inventoryScope: AssignmentInventoryScope,
lockMode: CellInventoryLockMode,
preferredRegion?: RelayRegion,
placementRegion: RelayRegion = preferredRegion ?? RELAY_DEFAULT_REGION
placementRegion: RelayRegion = preferredRegion ?? RELAY_DEFAULT_REGION,
epochFloor = 0
): Promise<RelayAssignment> {
const now = this.now()
// An isolated retry takes its tier's rows before anything reassigns this,
// so a lock timeout there must retry the same tier, not fall to 'all'.
let retryScope: RetriedAssignmentInventoryScope =
inventoryScope === 'none' ? 'general' : inventoryScope
await this.reserveModeCells()
// Why the events ride back out rather than being written where they are
// decided: everything below runs in one transaction, and a reservation or
// lease write that fails after the decision rolls the placement back. A
@@ -1049,7 +1345,12 @@ export class RelayAssignmentStore {
const existing = await this.assignmentRowOrBusy(transaction, identity)
// A dormant host holds no units, so its placement never writes its old
// cell and needs only the rows it could land on.
const dormant = !existing || mayNormallyReassign(activity(existing), now)
// A live reserve cell's hosts hold no activity rows here: idle there is not dormant. A
// dead one holds no duplicate, so its hosts are re-placed as any dormant host is.
const dormant =
!existing ||
(mayNormallyReassign(activity(existing), now) &&
!(await this.liveReserveCell(text(existing, 'cell_id'), transaction, now)))
retryScope = dormant ? 'general' : isolatedScope ? inventoryScope : 'all'
if (
(inventoryScope === 'general' && !dormant) ||
@@ -1169,6 +1470,19 @@ export class RelayAssignmentStore {
current.cellId,
integer(existing, 'assignment_epoch')
)
// A reserve cell counts its own controls: the pin is returned with no row written,
// since the sweeps that would release those units skip this cell.
if (!hadControl && (await this.mayBeReserveCell(current.cellId, transaction))) {
return {
assignment: this.result(
identity,
existing,
current,
now + ASSIGNMENT_LIMITS.activityLeaseMs
),
events
}
}
const hasConnectionHeadroom =
hadControl ||
(await this.cellHasConnectionHeadroom(transaction, current.cellId))
@@ -1283,7 +1597,9 @@ export class RelayAssignmentStore {
}
}
await this.adjustCellReservation(transaction, target.cellId, 1)
const assignmentEpoch = existing ? integer(existing, 'assignment_epoch') + 1 : 1
// A host that left a reserve-mode cell inside the ledger's lag never reuses an epoch.
const assignmentEpoch =
Math.max(existing ? integer(existing, 'assignment_epoch') : 0, epochFloor) + 1
// Counters follow the leases the host still holds, plus the new control.
const counts = activityCounts(keptLeases)
counts.control += 1
@@ -2301,6 +2617,8 @@ export class RelayAssignmentStore {
traceValue: string
plannedGraceMs: number
}): Promise<CellDrainAttempt & { shouldSend: false }> {
// Rolling a switched-on cell needs a flip back first (design default 4).
await this.refuseReserveCell(input.cellId)
const now = this.now()
return await this.database.transaction(async (transaction) => {
await this.assertDrainCellGeneration(
@@ -2976,7 +3294,13 @@ export class RelayAssignmentStore {
async evacuateDeadCells(limit = 100): Promise<number> {
if (!this.requireLiveCells) return 0
const cutoff = this.now() - this.heartbeatTtlMs
const cellIds = await this.deadCellEvacuationCandidates(cutoff)
const reserveCells = await this.reserveModeCells()
if (reserveCells === null) return 0
// A reserve-mode cell that stops heartbeating is unverifiable, never gone: its hosts
// re-place themselves when their control drops.
const cellIds = (await this.deadCellEvacuationCandidates(cutoff)).filter(
(cellId) => !reserveCells.has(cellId)
)
// Why: without a candidate cell the host query below walks every assignment by primary key to
// return nothing (574 ms per call in production, from stale existing-only cells it can never act on).
if (cellIds.length === 0) return 0
@@ -3196,6 +3520,8 @@ export class RelayAssignmentStore {
targetCellId: string,
limit: number
): Promise<number> {
await this.refuseReserveCell(sourceCellId)
await this.refuseReserveCell(targetCellId)
const rows = await this.database.query(
`SELECT user_id, relay_host_id FROM relay_assignments
WHERE cell_id = ? AND
@@ -3220,6 +3546,8 @@ export class RelayAssignmentStore {
sourceCellId: string,
targetCellId: string
): Promise<CellEvacuationCapacity> {
await this.refuseReserveCell(sourceCellId)
await this.refuseReserveCell(targetCellId)
const cells = await this.database.query(
`SELECT * FROM relay_cells WHERE cell_id IN (?, ?) ORDER BY cell_id`,
[sourceCellId, targetCellId]
@@ -3262,6 +3590,8 @@ export class RelayAssignmentStore {
targetCellId: string,
completeReady: boolean
): Promise<CellEvacuationStatus> {
await this.refuseReserveCell(sourceCellId)
await this.refuseReserveCell(targetCellId)
let completed = 0
let blocked = 0
const sourceFenced = await this.cellHasActiveFence(sourceCellId)
@@ -3450,7 +3780,9 @@ export class RelayAssignmentStore {
return completed
}
// Its host counts here are stale for a reserve-mode cell, so tooling must not read it as empty.
async cellDeploymentStatus(cellId: string): Promise<CellDeploymentStatus> {
await this.refuseReserveCell(cellId)
const cellRow = (
await this.database.query(
`WITH activity AS (
@@ -3824,7 +4156,13 @@ export class RelayAssignmentStore {
selectionMs: performance.now() - startedAt,
skippedOffRegionSourceCells: selection.skippedOffRegionSourceCells
})
return selection.candidates
// Neither side of an idle rehome may be a reserve-mode cell: the commit moves rows here.
const reserveCells = await this.reserveModeCells()
if (reserveCells === null) return []
return selection.candidates.filter(
(candidate) =>
!reserveCells.has(candidate.sourceCellId) && !reserveCells.has(candidate.targetCellId)
)
}
async commitIdleRegionalRehome(
@@ -4091,9 +4429,11 @@ export class RelayAssignmentStore {
message: String((error as { message?: unknown }).message)
})
)
// No connection at all: per-host statements would only queue up behind the
// same saturated pool, multiplying the load that starved this flush.
if (isPostgresPoolConnectFailure(error)) return outcomes.fill('database_error')
// No connection at all, or a lost reply: per-host statements would only queue up
// behind the same saturated pool or stalled server, multiplying the load.
if (isPostgresPoolConnectFailure(error) || isPostgresReadTimeout(error)) {
return outcomes.fill('database_error')
}
await Promise.all(
ordered.map(async (row) => {
try {
@@ -4256,15 +4596,38 @@ export class RelayAssignmentStore {
async activateControl(
identity: AssignmentIdentity,
input: {
cellId: string
assignmentEpoch: number
generation: number
idleRegionalRehome?: boolean
cellIncarnation?: string
connectionInclusionWatermark?: number
}
input: ControlActivationInput
): Promise<string> {
return (await this.activateControlOnce(identity, input, false)).activityId
}
// Step 5 flip back: the same activation, but the cell-row delta is returned for the caller
// to fold into one relay_cells write per batch (commitCellReservationDelta), not committed
// here. A dozen re-registrations from Asia each holding that row's lock convoy on it.
async activateControlDeferringCell(
identity: AssignmentIdentity,
input: ControlActivationInput
): Promise<{ activityId: string; reservationDelta: number }> {
return await this.activateControlOnce(identity, input, true)
}
// Unguarded by capacity: these hosts are already connected to the cell.
async commitCellReservationDelta(cellId: string, delta: number): Promise<void> {
if (delta === 0) return
await this.database.query(
`UPDATE relay_cells SET reserved_requests =
CASE WHEN reserved_requests + ? < 0 THEN 0 ELSE reserved_requests + ? END,
updated_at = ?
WHERE cell_id = ?`,
[delta, delta, this.now(), cellId]
)
}
private async activateControlOnce(
identity: AssignmentIdentity,
input: ControlActivationInput,
deferCell: boolean
): Promise<{ activityId: string; reservationDelta: number }> {
const activityId = `control:${input.cellId}:${input.generation}`
validateActivityId(activityId)
return await this.activityQueue.run(identity, async () => {
@@ -4376,14 +4739,14 @@ export class RelayAssignmentStore {
// every host on the cell, and this transaction spans a dozen round
// trips. Holding its write lock from the first of them capped a
// far-from-Postgres cell at a couple of accepts a second.
if (reservationDelta !== 0) {
if (reservationDelta !== 0 && !deferCell) {
await this.commitCellReservationAtomically(
transaction,
input.cellId,
reservationDelta
)
}
return activityId
return { activityId, reservationDelta: deferCell ? reservationDelta : 0 }
})
})
}
@@ -4403,6 +4766,7 @@ export class RelayAssignmentStore {
expectedSourceCellId?: string
): Promise<RelayAssignmentMigration | null> {
const now = this.now()
await this.reserveModeCells()
return await this.database.transaction(async (transaction) => {
const assignment = await this.assignmentRow(transaction, identity)
if (!assignment) throw new Error('assignment_not_found')
@@ -4426,6 +4790,8 @@ export class RelayAssignmentStore {
return migration(identity, existing)
}
if (sourceCellId === targetCellId) throw new Error('target_matches_source')
await this.refuseReserveCell(sourceCellId, transaction)
await this.refuseReserveCell(targetCellId, transaction)
await this.lockAssignmentActivities(transaction, identity)
await this.lockControlConnectionReservations(transaction, identity)
const cells = await this.lockCellInventory(transaction, 'request')
@@ -4631,7 +4997,12 @@ export class RelayAssignmentStore {
},
inventoryFirst: boolean
): Promise<DeadSourceCompletionResult> {
// A heartbeating reserve source keeps no activity rows, so its hosts are not provably off
// it. A dead one holds no duplicate.
const now = this.now()
if (await this.cellIsLive(this.database, input.sourceCellId, now)) {
await this.refuseReserveCell(input.sourceCellId)
}
return await this.database.transaction(async (transaction) => {
await this.lockControlConnectionReservations(transaction, identity)
let lockedCells: SqlRow[] | undefined
@@ -5511,6 +5882,7 @@ export class RelayAssignmentStore {
assignmentEpoch: number
): Promise<void> {
const now = this.now()
await this.reserveModeCells()
await this.database.transaction(async (transaction) => {
const assignment = await this.assignmentRow(transaction, identity)
const row = (
@@ -5537,6 +5909,8 @@ export class RelayAssignmentStore {
const activityLeases = await this.lockAssignmentActivities(transaction, identity)
const sourceUnits = activityUnitsForCell(activityLeases, sourceCellId)
if (sourceUnits > 0) throw new Error('migration_source_still_active')
// A reserve source keeps no activity rows, so "no source units" proves nothing there.
await this.refuseReserveCell(sourceCellId, transaction)
let cellsLocked = false
let targetStartedAt = 0
if (this.requireLiveCells) {
@@ -5613,12 +5987,15 @@ export class RelayAssignmentStore {
targetCellId: string
): Promise<RelayAssignment> {
const now = this.now()
await this.reserveModeCells()
return await this.database.transaction(async (transaction) => {
const assignment = await this.assignmentRow(transaction, identity)
if (!assignment) throw new Error('assignment_not_found')
if (!mayNormallyReassign(activity(assignment), now)) throw new Error('assignment_active')
const sourceCellId = text(assignment, 'cell_id')
if (sourceCellId === targetCellId) throw new Error('target_matches_source')
await this.refuseReserveCell(sourceCellId, transaction)
await this.refuseReserveCell(targetCellId, transaction)
await this.lockAssignmentActivities(transaction, identity)
await this.lockControlConnectionReservations(transaction, identity)
const cells = await this.lockCellInventory(transaction, 'request')
@@ -7316,6 +7693,8 @@ export class RelayAssignmentStore {
}
async releaseExpiredActivityLeases(): Promise<number> {
const reserveCells = await this.reserveModeCells()
if (reserveCells === null) return 0
const now = this.now()
await this.database.query(
`UPDATE relay_control_connection_reservations
@@ -7334,7 +7713,7 @@ export class RelayAssignmentStore {
const candidates = await this.database.query(
`SELECT user_id, relay_host_id, activity_id
FROM relay_assignment_activity_leases lease
WHERE expires_at <= ?
WHERE expires_at <= ?${excludeCellsSql('lease.cell_id', [...reserveCells])}
AND NOT EXISTS (
SELECT 1 FROM relay_region_rehome_attempts rehome
WHERE rehome.user_id = lease.user_id
@@ -7360,7 +7739,7 @@ export class RelayAssignmentStore {
'migration:' || CAST(pin.assignment_epoch AS TEXT)
)
)`,
[now]
[now, ...reserveCells]
)
let released = 0
for (const candidate of candidates) {
@@ -7395,13 +7774,18 @@ export class RelayAssignmentStore {
}
async releaseExpiredActivity(): Promise<number> {
const reserveCells = await this.reserveModeCells()
if (reserveCells === null) return 0
const now = this.now()
try {
return await this.database.transaction(async (transaction) => {
// Only counters no lease backs. A host's leases can sit on more than
// one cell, and the lease sweep takes each one's units off its own.
const expired = await transaction.queryLocked(
`SELECT * FROM relay_assignments WHERE lease_expires_at <= ? AND
`SELECT * FROM relay_assignments WHERE lease_expires_at <= ?${excludeCellsSql(
'relay_assignments.cell_id',
[...reserveCells]
)} AND
(reserved_controls > 0 OR reserved_splices > 0 OR reserved_invites > 0 OR
pending_installs > 0 OR pending_confirmations > 0 OR migration_leases > 0)
AND NOT EXISTS (
@@ -7427,7 +7811,7 @@ export class RelayAssignmentStore {
AND migration.aborted_at IS NULL
)
ORDER BY user_id, relay_host_id`,
[now],
[now, ...reserveCells],
{ failIfUnavailable: true }
)
if (expired.length > 0) await this.lockCellInventory(transaction, 'nowait')
@@ -7787,37 +8171,120 @@ export class RelayAssignmentStore {
)
: null
const connectionHeadroom = await this.connectionHeadroomByCell(database)
// The database pool: a reserve-mode cell takes only the bookings it admits itself. Unknown
// keeps the last set read (stale), not none: placement must not stop with a slow read.
const reserveCells = (await this.reserveModeCells(database)) ?? this.reserveModeRead?.cells ?? new Set<string>()
const candidates = rows.filter((row) => {
const cellId = text(row, 'cell_id')
return (
// A reserve cell whose own feed says it admits through the database again (its dead-man
// tripped, or a flip back finished) takes placements; the census sweeps still skip it.
(!reserveCells.has(cellId) || this.reserveCellAdmitsDatabase(cellId)) &&
admission.get(cellId) === 'general' &&
integer(row, 'reserved_requests') < integer(row, 'capacity_requests') &&
(!runtimeLoad || runtimeLoad.has(cellId)) &&
connectionHeadroom.get(cellId) !== false
)
})
candidates.sort((left, right) => {
const leftLoad =
integer(left, 'reserved_requests') +
(runtimeLoad?.get(text(left, 'cell_id')) ?? integer(left, 'observed_requests'))
const rightLoad =
integer(right, 'reserved_requests') +
(runtimeLoad?.get(text(right, 'cell_id')) ?? integer(right, 'observed_requests'))
const loadDifference =
leftLoad / integer(left, 'capacity_requests') -
rightLoad / integer(right, 'capacity_requests')
return loadDifference || text(left, 'cell_id').localeCompare(text(right, 'cell_id'))
})
const preferred = candidates.filter(
(candidate) =>
(regions.get(text(candidate, 'cell_id')) ?? RELAY_DEFAULT_REGION) === preferredRegion
const loadRatio = (row: SqlRow) =>
(integer(row, 'reserved_requests') +
(runtimeLoad?.get(text(row, 'cell_id')) ?? integer(row, 'observed_requests'))) /
integer(row, 'capacity_requests')
const ranked = candidates
.map((row) => ({ row, cellId: text(row, 'cell_id'), loadRatio: loadRatio(row) }))
.sort(
(left, right) =>
left.loadRatio - right.loadRatio || left.cellId.localeCompare(right.cellId)
)
const preferred = ranked.filter(
(candidate) => (regions.get(candidate.cellId) ?? RELAY_DEFAULT_REGION) === preferredRegion
)
const selected = regionMode === 'require' ? preferred[0] : (preferred[0] ?? candidates[0])
const pool = regionMode === 'require' || preferred.length > 0 ? preferred : ranked
const selected = this.placementLoadBand
? this.placementLoadBand.pick(pool, this.now())
: pool[0]
return selected
? cell(selected, regions.get(text(selected, 'cell_id')) ?? RELAY_DEFAULT_REGION)
? cell(selected.row, regions.get(selected.cellId) ?? RELAY_DEFAULT_REGION)
: null
}
// Every cell, enabled or not: an existing-only cell still seats hosts. Liveness is
// placement's own rule (ready, heartbeat within heartbeatTtlMs).
async seatFeedCells(): Promise<SeatFeedCell[]> {
const now = this.now()
const rows = await this.database.query(
`SELECT cell.cell_id, cell.cell_url, cell.capacity_requests, region.region,
admission.admission_state, admission.roll_isolated_at, runtime.ready,
runtime.last_heartbeat_at, runtime.cell_incarnation
FROM relay_cells cell
LEFT JOIN relay_cell_regions region ON region.cell_id = cell.cell_id
LEFT JOIN relay_cell_admission admission ON admission.cell_id = cell.cell_id
LEFT JOIN relay_cell_runtime runtime ON runtime.cell_id = cell.cell_id
ORDER BY cell.cell_id ASC`
)
return rows.map((row) => {
const lastHeartbeatAt = optionalInteger(row, 'last_heartbeat_at')
const heartbeatExpiresAt = !this.requireLiveCells
? Number.MAX_SAFE_INTEGER
: optionalInteger(row, 'ready') === 1 && lastHeartbeatAt !== undefined
? lastHeartbeatAt + this.heartbeatTtlMs
: null
return {
cellId: text(row, 'cell_id'),
cellUrl: text(row, 'cell_url'),
region: optionalRelayRegion(row, 'region') ?? RELAY_DEFAULT_REGION,
heartbeatExpiresAt,
requiredForComplete:
heartbeatExpiresAt !== null &&
heartbeatExpiresAt > now &&
integer(row, 'capacity_requests') > 0 &&
optionalInteger(row, 'roll_isolated_at') === undefined,
// Step 5 places only on general cells that are not isolated for a roll.
general:
optionalText(row, 'admission_state') === 'general' &&
optionalInteger(row, 'roll_isolated_at') === undefined &&
integer(row, 'capacity_requests') > 0,
...incarnationField(optionalText(row, 'cell_incarnation'))
}
})
}
// On-call "where is host X: primary-key reads only, no liveness filter.
async hostWhereabouts(identity: AssignmentIdentity): Promise<HostWhereaboutsRow | null> {
const row = (
await this.database.query(
`SELECT cell_id, assignment_epoch, lease_expires_at FROM relay_assignments
WHERE user_id = ? AND relay_host_id = ?`,
[identity.userId, identity.relayHostId]
)
)[0]
if (!row) return null
const migrations = await this.database.query(
`SELECT source_cell_id, target_cell_id, previous_epoch, assignment_epoch, expires_at,
target_registered_at
FROM relay_assignment_migrations
WHERE user_id = ? AND relay_host_id = ? AND completed_at IS NULL AND aborted_at IS NULL
ORDER BY assignment_epoch DESC LIMIT 1`,
[identity.userId, identity.relayHostId]
)
const migration = migrations[0]
return {
cellId: text(row, 'cell_id'),
assignmentEpoch: integer(row, 'assignment_epoch'),
leaseExpiresAt: integer(row, 'lease_expires_at'),
openMigration: migration
? {
sourceCellId: text(migration, 'source_cell_id'),
targetCellId: text(migration, 'target_cell_id'),
previousEpoch: integer(migration, 'previous_epoch'),
assignmentEpoch: integer(migration, 'assignment_epoch'),
expiresAt: integer(migration, 'expires_at'),
targetRegisteredAt: optionalInteger(migration, 'target_registered_at') ?? null
}
: null
}
}
async regionCatalog(): Promise<RelayRegionCatalogEntry[]> {
const rows = await this.database.query(
`SELECT cell.cell_id, cell.cell_url, region.region
@@ -9357,6 +9824,10 @@ function optionalInteger(row: SqlRow, field: string): number | undefined {
return integer(row, field)
}
function incarnationField(incarnation: string | undefined): { incarnation?: string } {
return incarnation === undefined ? {} : { incarnation }
}
function optionalText(row: SqlRow, field: string): string | undefined {
if (row[field] === null || row[field] === undefined) return undefined
return text(row, field)
@@ -28,6 +28,8 @@ export async function reconcileCellAdmissionAtStartup(
// Admission is operator/director state. A new worker must not enable itself
// before its distinct candidate has passed production preflight.
if (config.role === 'cell') return
// Retries every transient failure, a lost reply included: the reconcile is upserts and
// limit-change deletes that converge on the same rows if a lost COMMIT had landed.
await retryTransientDatabaseStartup(
async () => await assignments.reconcileCellsAtStartup(config.cells),
STARTUP_RECONCILE_RETRY,
@@ -0,0 +1,88 @@
import { describe, expect, it } from 'vitest'
import { CELL_ADMIT_EFFECTIVE_REFRESH_MS, CellAdmitEffectiveWriter } from './cell-admit-effective-writer.js'
function setup(options: { fail?: boolean; busy?: boolean } = {}) {
const clock = { value: 1_000 }
const writes: Array<'db' | 'reserve'> = []
const pending: Array<() => void> = []
const logs: string[] = []
const writer = new CellAdmitEffectiveWriter({
write: async (mode) => {
writes.push(mode)
await new Promise<void>((resolve) => pending.push(resolve))
if (options.fail) throw new Error('connection refused')
},
databaseBusy: () => options.busy === true,
now: () => clock.value,
log: (line) => logs.push(line)
})
const settle = async () => {
while (pending.length > 0) pending.shift()!()
await new Promise((resolve) => setTimeout(resolve, 0))
}
return { clock, writes, logs, writer, settle }
}
describe('CellAdmitEffectiveWriter', () => {
it('writes on the first tick, on every change, and every 15 s, one write at a time', async () => {
const { clock, writes, writer, settle } = setup()
writer.tick('reserve', true)
writer.tick('db', true)
expect(writes).toEqual(['reserve'])
await settle()
// Tripped: the switch still says reserve, the raw mode db.
writer.tick('db', true)
await settle()
writer.tick('db', true)
expect(writes).toEqual(['reserve', 'db'])
clock.value += CELL_ADMIT_EFFECTIVE_REFRESH_MS
writer.tick('db', true)
expect(writes).toEqual(['reserve', 'db', 'db'])
})
// Step 5 off: a db switch never writes, so the dark roll adds no database write.
it('writes nothing while the switch has never said reserve, and one db write on the way out', async () => {
const { clock, writes, writer, settle } = setup()
writer.tick('db', false)
clock.value += 10 * CELL_ADMIT_EFFECTIVE_REFRESH_MS
writer.tick('db', false)
expect(writes).toEqual([])
writer.tick('reserve', true)
await settle()
writer.tick('reserve', false)
await settle()
expect(writes).toEqual(['reserve', 'db'])
clock.value += 10 * CELL_ADMIT_EFFECTIVE_REFRESH_MS
writer.tick('db', false)
expect(writes).toEqual(['reserve', 'db'])
})
it('owes nothing more once a tripped cell already wrote db', async () => {
const { clock, writes, writer, settle } = setup()
writer.tick('db', true)
await settle()
clock.value += CELL_ADMIT_EFFECTIVE_REFRESH_MS
writer.tick('db', false)
writer.tick('db', false)
expect(writes).toEqual(['db'])
})
it('takes no connection while requests wait for the pool', () => {
const { writes, writer } = setup({ busy: true })
writer.tick('db', true)
expect(writes).toEqual([])
})
// Best-effort: a failed write never throws into the cell, logs at most once a minute, and retries.
it('retries a failed write on the next tick and rate-limits its log line', async () => {
const { clock, writes, logs, writer, settle } = setup({ fail: true })
writer.tick('db', true)
await settle()
clock.value += 1_000
writer.tick('db', true)
await settle()
expect(writes).toEqual(['db', 'db'])
expect(logs).toHaveLength(1)
expect(JSON.parse(logs[0]!)).toMatchObject({ event: 'orca_relay_cell_admit_effective_write_failed', mode: 'db' })
})
})
@@ -0,0 +1,63 @@
// The cell's raw admit mode in Postgres (relay_cell_admit_effective), so the database path can
// place on a tripped reserve cell even when no director's feed poll reaches it. Only the database
// path's PG-reserve cells need a row, so a cell writes only while its switch says reserve, plus
// one db write on the way out: with step 5 off, no cell writes anything.
export const CELL_ADMIT_EFFECTIVE_REFRESH_MS = 15_000
const CELL_ADMIT_EFFECTIVE_FAILURE_LOG_MS = 60_000
export class CellAdmitEffectiveWriter {
private written: { mode: 'db' | 'reserve'; at: number } | null = null
private inFlight = false
// A reserve row may stand: the db write that replaces it is still owed.
private owed = false
private failureLoggedAt = Number.NEGATIVE_INFINITY
constructor(
private readonly input: {
write: (mode: 'db' | 'reserve') => Promise<void>
// Never takes a connection a waiting request needs; the next tick tries again.
databaseBusy: () => boolean
now: () => number
log?: (line: string) => void
}
) {}
// Best-effort: never awaited, never throws, at most one write in flight. `switchReserve` is the
// applied switch file's admitMode (a tripped dead-man keeps it reserve, with `mode` db).
tick(rawMode: 'db' | 'reserve', switchReserve: boolean): void {
if (switchReserve) this.owed = true
else if (!this.owed) return
else if (this.written?.mode === 'db' && !this.inFlight) {
this.owed = false
return
}
const mode = switchReserve ? rawMode : 'db'
const at = this.input.now()
const due =
this.written === null ||
this.written.mode !== mode ||
at - this.written.at >= CELL_ADMIT_EFFECTIVE_REFRESH_MS
if (!due || this.inFlight || this.input.databaseBusy()) return
this.inFlight = true
void this.input
.write(mode)
.then(() => {
this.written = { mode, at }
if (!switchReserve) this.owed = false
})
.catch((error: unknown) => {
if (at - this.failureLoggedAt < CELL_ADMIT_EFFECTIVE_FAILURE_LOG_MS) return
this.failureLoggedAt = at
;(this.input.log ?? ((line: string) => console.warn(line)))(
JSON.stringify({
event: 'orca_relay_cell_admit_effective_write_failed',
mode,
reason: error instanceof Error ? error.message : 'unknown'
})
)
})
.finally(() => {
this.inFlight = false
})
}
}
+113
View File
@@ -0,0 +1,113 @@
import { z } from 'zod'
import type { ControlFlagParse } from './relay-control-flag-channel.js'
// Every switch defaults to off. A newer writer may name flags this image lacks, so unknown
// keys are dropped; a known key with a bad value voids the object.
export type CellFlags = {
readinessLocal: boolean
// `enforce` admits a hello on a valid lease alone; only reserve mode reads it.
ticketCheck: 'off' | 'shadow' | 'enforce'
// Off restores Node's default: any unhandled rejection ends the process (#26817 reverted).
rejectionFence: boolean
// Past statement_timeout before a lost reply ends its connection; absent keeps the boot value.
readTimeoutMarginMs?: number
// Step 5: `reserve` admits booked and recently seated hosts without the database.
admitMode: 'db' | 'reserve'
// Bookings per second this cell accepts; absent means its region's default.
intakePerSec?: number
// Answers a director's dry-run booking check; off, the reserve endpoint is inert in db mode.
reserveDryRun: boolean
// Flip-back leases in flight at once; absent is a third of the pool. Speeds up one cell.
reregisterInFlight?: number
}
export const CELL_FLAG_DEFAULTS: CellFlags = {
readinessLocal: false,
ticketCheck: 'off',
rejectionFence: true,
admitMode: 'db',
reserveDryRun: false
}
export const READ_TIMEOUT_MARGIN_MIN_MS = 1_000
export const READ_TIMEOUT_MARGIN_MAX_MS = 60_000
// Until E-pre measures each cell's accept rate (S3 §4.2).
export function defaultIntakePerSec(region: string | undefined): number {
return region === 'asia-east2' ? 2 : 5
}
export const CELL_FLAGS_APPLIED_EVENT = 'orca_relay_cell_flags_applied'
const CellFlagsSchema = z.object({
readinessLocal: z.boolean().default(CELL_FLAG_DEFAULTS.readinessLocal),
ticketCheck: z.enum(['off', 'shadow', 'enforce']).default(CELL_FLAG_DEFAULTS.ticketCheck),
rejectionFence: z.boolean().default(CELL_FLAG_DEFAULTS.rejectionFence),
readTimeoutMarginMs: z
.number()
.int()
.min(READ_TIMEOUT_MARGIN_MIN_MS)
.max(READ_TIMEOUT_MARGIN_MAX_MS)
.optional(),
admitMode: z.enum(['db', 'reserve']).default(CELL_FLAG_DEFAULTS.admitMode),
intakePerSec: z.number().positive().max(1_000).optional(),
reserveDryRun: z.boolean().default(CELL_FLAG_DEFAULTS.reserveDryRun),
reregisterInFlight: z.number().int().min(1).max(16).optional()
})
const KNOWN_FLAG_KEYS = new Set(Object.keys(CellFlagsSchema.shape))
export type SupportedCellFlag =
| { type: 'boolean' }
| { type: 'enum'; values: string[] }
| { type: 'number'; min?: number; max?: number; integer?: boolean }
// Parsed, never offered: ticketCheck=enforce has no step that turns it on yet.
const UNOFFERED_FLAG_VALUES: Record<string, string[]> = { ticketCheck: ['enforce'] }
// What this image accepts, read from the schema itself: the flag tool refuses a key or value
// the cell would drop or void before it writes.
export function supportedCellFlags(): Record<string, SupportedCellFlag> {
const supported: Record<string, SupportedCellFlag> = {}
for (const [key, field] of Object.entries(CellFlagsSchema.shape)) {
let inner: z.ZodTypeAny = field
while (inner instanceof z.ZodDefault || inner instanceof z.ZodOptional) {
inner = inner instanceof z.ZodDefault ? inner.removeDefault() : inner.unwrap()
}
if (inner instanceof z.ZodBoolean) supported[key] = { type: 'boolean' }
else if (inner instanceof z.ZodEnum) {
const unoffered = UNOFFERED_FLAG_VALUES[key] ?? []
supported[key] = { type: 'enum', values: inner.options.filter((value: string) => !unoffered.includes(value)) }
}
else if (inner instanceof z.ZodNumber) {
supported[key] = {
type: 'number',
...(inner.minValue === null ? {} : { min: inner.minValue }),
...(inner.maxValue === null ? {} : { max: inner.maxValue }),
...(inner.isInt ? { integer: true } : {})
}
}
}
return supported
}
export function cellFlagObjectName(cellId: string): string {
return `cells/${cellId}.json`
}
export function cellFlagParser(cellId: string): ControlFlagParse<CellFlags> {
const ObjectSchema = z.object({
v: z.literal(1),
cellId: z.literal(cellId),
flags: CellFlagsSchema
})
return (body) => {
const parsed = ObjectSchema.safeParse(body)
if (!parsed.success) return null
const named = body !== null && typeof body === 'object' && 'flags' in body ? body.flags : {}
const ignoredKeys =
named !== null && typeof named === 'object'
? Object.keys(named).filter((key) => !KNOWN_FLAG_KEYS.has(key)).sort()
: []
return { flags: parsed.data.flags, ignoredKeys }
}
}
@@ -0,0 +1,444 @@
import { createHash, createHmac } from 'node:crypto'
import { createServer, type Server } from 'node:http'
import { createServer as createNetServer } from 'node:net'
import { buildHostProofMacInput, HOST_CHALLENGE_PLAINTEXT_DOMAIN } from '@orca-cloud/relay-contract'
import { exportJWK, generateKeyPair, SignJWT } from 'jose'
import nacl from 'tweetnacl'
import { afterEach, describe, expect, it, vi } from 'vitest'
import WebSocket from 'ws'
import type { RawData } from 'ws'
import { CELL_FLAG_DEFAULTS, type CellFlags } from './cell-flags.js'
import type { RelayConfig } from './config.js'
import { PostgresDatabase } from './database.js'
import type { AppliedControlFlags } from './relay-control-flag-channel.js'
import { createRelayServer, HOST_HELLO_SHED_OLDEST_WAIT_MS } from './relay-server.js'
// A step-5 cell whose database pool is wedged: every query waits forever and the hello shed
// is armed. Booked and recently seated hosts must still connect; everything else must not.
async function unusedPort(): Promise<number> {
const server = createNetServer()
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
const address = server.address()
if (!address || typeof address === 'string') throw new Error('missing test port')
await new Promise<void>((resolve) => server.close(() => resolve()))
return address.port
}
function stalledPool() {
const pool = {
options: { max: 2, connectionTimeoutMillis: 0 },
totalCount: 2,
idleCount: 0,
waitingCount: 0,
connect: () => {
pool.waitingCount++
return new Promise<never>(() => {})
},
end: async () => undefined
}
return pool
}
function cellConfig(port: number, issuer: string): RelayConfig {
const relayUrl = `http://127.0.0.1:${port}`
return {
port,
publicUrl: relayUrl,
cellUrl: relayUrl,
authIssuer: issuer,
authAudience: 'orca-relay',
jwksUrl: issuer,
assignmentSigningKey: new Uint8Array(32),
role: 'cell',
cellId: 'production-gce-c3',
cells: [{ id: 'production-gce-c3', url: relayUrl, capacityRequests: 4_000 }],
adminAudience: `${relayUrl}/admin`,
deployServiceAccount: 'deploy@example.com',
runtimeServiceAccount: 'runtime@example.com',
connectionHardCap: 600,
connectionUnobservedBound: 60,
adminJwksUrl: `${issuer}/admin-jwks`,
databasePoolMax: 2,
publicAssignmentsEnabled: true,
publicAssignmentConcurrency: 1,
publicAssignmentQueueMax: 128,
publicAssignmentWaitMs: 4_000,
publicResolveConcurrency: 1,
publicResolveWaitMs: 5_000,
publicAssignmentRetryAfterSeconds: 5,
dataDir: './test-data'
}
}
function nextMessage(socket: WebSocket): Promise<Record<string, unknown>> {
return new Promise((resolve, reject) => {
socket.once('message', (data: RawData) => {
try {
resolve(JSON.parse(data.toString()) as Record<string, unknown>)
} catch (error) {
reject(error)
}
})
socket.once('error', reject)
})
}
describe('a reserve-mode cell admits from memory with its database wedged', () => {
const cleanup: Array<() => Promise<void> | void> = []
afterEach(async () => {
for (const close of cleanup.splice(0).reverse()) await close()
vi.restoreAllMocks()
})
async function startCell(
flags: Partial<CellFlags>,
connectionLedgerLimits?: { hardCap: number; controlReserve: number }
) {
vi.spyOn(console, 'log').mockImplementation(() => undefined)
vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const keys = await generateKeyPair('ES256')
const publicJwk = await exportJWK(keys.publicKey)
const jwksServer: Server = createServer((_request, response) => {
response.setHeader('content-type', 'application/json')
response.end(
JSON.stringify({ keys: [{ ...publicJwk, kid: 'test-key', alg: 'ES256', use: 'sig' }] })
)
})
await new Promise<void>((resolve) => jwksServer.listen(0, '127.0.0.1', resolve))
cleanup.push(() => new Promise<void>((resolve) => jwksServer.close(() => resolve())))
const jwksAddress = jwksServer.address()
if (!jwksAddress || typeof jwksAddress === 'string') throw new Error('missing JWKS address')
const issuer = `http://127.0.0.1:${jwksAddress.port}`
const realNow = Date.now()
let elapsedMs = 0
vi.spyOn(Date, 'now').mockImplementation(() => realNow + elapsedMs)
const database = new PostgresDatabase(stalledPool() as never)
for (let index = 0; index < 2; index++) void database.query('SELECT 1').catch(() => {})
elapsedMs += HOST_HELLO_SHED_OLDEST_WAIT_MS
let applied: AppliedControlFlags<CellFlags> = {
generation: 3,
flags: { ...CELL_FLAG_DEFAULTS, ...flags }
}
const port = await unusedPort()
const relay = createRelayServer(cellConfig(port, issuer), database, {
cellFlags: () => applied,
...(connectionLedgerLimits ? { connectionLedgerLimits } : {})
})
relay.server.listen(port, '127.0.0.1')
await new Promise<void>((resolve) => relay.server.once('listening', resolve))
cleanup.push(() => new Promise<void>((resolve) => relay.server.close(() => resolve())))
const host = async () => {
const keyPair = nacl.box.keyPair()
const hostId = createHash('sha256').update(keyPair.publicKey).digest('base64url').slice(0, 16)
const token = await new SignJWT({
prof: 'profile-1',
org: 'org-1',
purpose: 'host-control',
relayHostId: hostId
})
.setProtectedHeader({ alg: 'ES256', kid: 'test-key' })
.setIssuer(issuer)
.setAudience('orca-relay')
.setSubject('user-1')
.setIssuedAt()
.setExpirationTime('5m')
.sign(keys.privateKey)
return { keyPair, hostId, token }
}
// The upgrade status, or the first control frame after the proof.
const connect = async (
identity: Awaited<ReturnType<typeof host>>,
epoch: number
): Promise<{
status: number
ack?: Record<string, unknown>
socket?: WebSocket
closeCode?: Promise<number>
}> => {
const socket = new WebSocket(`ws://127.0.0.1:${port}/v1/host/control`, {
headers: { authorization: `Bearer ${identity.token}` },
perMessageDeflate: false
})
const opened = await new Promise<{ status: number }>((resolve, reject) => {
socket.once('unexpected-response', (request, response) => {
resolve({ status: response.statusCode ?? 0 })
request.destroy()
})
socket.once('open', () => resolve({ status: 101 }))
socket.once('error', reject)
})
if (opened.status !== 101) return opened
cleanup.push(() => socket.terminate())
const closeCode = new Promise<number>((resolve) => socket.once('close', resolve))
socket.send(
JSON.stringify({
type: 'host-hello',
v: 1,
relayHostId: identity.hostId,
assignmentEpoch: epoch,
hostPublicKeyB64: Buffer.from(identity.keyPair.publicKey).toString('base64'),
appVersion: 'test'
})
)
const challenge = await Promise.race([
nextMessage(socket),
closeCode.then(() => null),
new Promise<null>((resolve) => setTimeout(() => resolve(null), 500))
])
if (!challenge || challenge.type !== 'host-challenge') return { status: 101, socket, closeCode }
const plaintext = nacl.box.open(
Buffer.from(String(challenge.ciphertextB64), 'base64'),
Buffer.from(String(challenge.nonceB64), 'base64'),
Buffer.from(String(challenge.relayEphemeralPublicKeyB64), 'base64'),
identity.keyPair.secretKey
)!
const domainLength = new TextEncoder().encode(`${HOST_CHALLENGE_PLAINTEXT_DOMAIN}\0`).length
const transcriptLength = new DataView(
plaintext.buffer,
plaintext.byteOffset + domainLength,
4
).getUint32(0, false)
const transcriptStart = domainLength + 4
const transcript = plaintext.slice(transcriptStart, transcriptStart + transcriptLength)
const secret = plaintext.slice(transcriptStart + transcriptLength)
socket.send(
JSON.stringify({
type: 'host-challenge-ack',
challengeId: challenge.challengeId,
proofB64: createHmac('sha256', secret)
.update(buildHostProofMacInput(transcript))
.digest('base64')
})
)
return { status: 101, ack: await nextMessage(socket), socket }
}
// An upgrade that never sends its hello, so it keeps its connection unit.
const upgradeOnly = async (identity: Awaited<ReturnType<typeof host>>): Promise<number> => {
const socket = new WebSocket(`ws://127.0.0.1:${port}/v1/host/control`, {
headers: { authorization: `Bearer ${identity.token}` },
perMessageDeflate: false
})
cleanup.push(() => socket.terminate())
return await new Promise<number>((resolve, reject) => {
socket.once('unexpected-response', (request, response) => {
resolve(response.statusCode ?? 0)
request.destroy()
})
socket.once('open', () => resolve(101))
socket.once('error', reject)
})
}
const book = (identity: Awaited<ReturnType<typeof host>>, epoch: number) =>
relay.sessions.reserve({
v: 1,
directorId: 'director-a',
items: [{ userId: 'user-1', relayHostId: identity.hostId, epoch, ttlMs: 30_000 }]
})
return {
relay,
host,
connect,
upgradeOnly,
book,
setFlags: (next: Partial<CellFlags>) => {
applied = { generation: applied.generation + 1, flags: { ...CELL_FLAG_DEFAULTS, ...next } }
}
}
}
it('answers a director dry run only while reserveDryRun is on, and books nothing either way', async () => {
const cell = await startCell({})
const identity = await cell.host()
const check = () =>
cell.relay.sessions.reserve({
v: 1,
directorId: 'director-a',
dryRun: true,
items: [{ userId: 'user-1', relayHostId: identity.hostId, epoch: 7, ttlMs: 30_000 }]
})
expect(check()).toEqual([{ outcome: 'off' }])
cell.setFlags({ reserveDryRun: true })
expect(check()).toEqual([{ outcome: 'ok' }])
expect(cell.relay.sessions.reserveCounts()?.bookings).toBe(0)
// A real booking on a database-mode cell stays off, whatever the dry-run switch says.
expect(cell.book(identity, 7)).toEqual([{ outcome: 'off' }])
})
it('connects a booked host, and only at its booked epoch, without a database read', async () => {
const cell = await startCell({ admitMode: 'reserve' })
const booked = await cell.host()
expect(cell.book(booked, 7)).toEqual([{ outcome: 'ok' }])
const stranger = await cell.host()
// No booking and no seat: the wedged pool sheds it, as today.
expect((await cell.connect(stranger, 7)).status).toBe(503)
const reply = await cell.connect(booked, 7)
expect(reply.ack).toMatchObject({ type: 'host-hello-ack', generation: 1 })
const page = cell.relay.sessions.seatFeed(null)
expect('full' in page && page.full).toMatchObject([{ relayHostId: booked.hostId, epoch: 7 }])
const changes = cell.relay.sessions.seatFeed(0)
expect('changes' in changes && changes.changes.map((change) => [change.kind, change.reservedBy])).toEqual([
['reserve', 'director-a'],
['join', 'director-a']
])
})
it('lets a host that just left rejoin at the same epoch from memory', async () => {
const cell = await startCell({ admitMode: 'reserve' })
const identity = await cell.host()
cell.book(identity, 4)
const first = await cell.connect(identity, 4)
expect(first.ack).toMatchObject({ type: 'host-hello-ack' })
first.socket!.close()
await new Promise((resolve) => setTimeout(resolve, 50))
expect((await cell.connect(identity, 4)).ack).toMatchObject({ type: 'host-hello-ack' })
// A different epoch is not the seat it remembers: shed at the hello, not queued on the pool.
const other = await cell.connect(identity, 5)
expect(other.ack).toBeUndefined()
expect(await other.closeCode).toBe(4429)
})
it('keeps today’s path when the switch is off, even for a booking made while it was on', async () => {
const cell = await startCell({ admitMode: 'reserve' })
const identity = await cell.host()
cell.book(identity, 2)
cell.setFlags({ admitMode: 'db' })
expect((await cell.connect(identity, 2)).status).toBe(503)
expect(cell.book(identity, 3)).toEqual([{ outcome: 'off' }])
})
it('demotes exactly the seat a director names, and repeats are one demotion', async () => {
const cell = await startCell({ admitMode: 'reserve' })
const identity = await cell.host()
cell.book(identity, 9)
await cell.connect(identity, 9)
const page = cell.relay.sessions.seatFeed(null)
const joinedAt = 'full' in page ? page.full[0]!.joinedAt : -1
const seat = { v: 1 as const, userId: 'user-1', relayHostId: identity.hostId, epoch: 9 }
expect(cell.relay.sessions.demote({ ...seat, joinedAt: joinedAt - 1 })).toBe('not-seated')
expect(cell.relay.sessions.demote({ ...seat, joinedAt })).toBe('demoted')
expect(cell.relay.sessions.demote({ ...seat, joinedAt })).toBe('already-demoted')
const changes = cell.relay.sessions.seatFeed(0)
expect('changes' in changes && changes.changes.at(-1)?.kind).toBe('drain-only')
// A demoted seat no longer holds its epoch against a new booking.
expect(cell.book(identity, 9)).toEqual([{ outcome: 'ok' }])
})
it('gives every booking unit back when the cell starts to drain', async () => {
const cell = await startCell({ admitMode: 'reserve' })
const identity = await cell.host()
cell.book(identity, 2)
expect(cell.relay.sessions.reserveCounts()?.bookings).toBe(1)
cell.relay.sessions.drain(30_000)
expect(cell.relay.sessions.reserveCounts()?.bookings).toBe(0)
expect(cell.book(identity, 3)).toEqual([{ outcome: 'draining' }])
})
it('takes no demotion on a database-mode cell', async () => {
const cell = await startCell({ admitMode: 'reserve' })
const identity = await cell.host()
cell.book(identity, 9)
await cell.connect(identity, 9)
const page = cell.relay.sessions.seatFeed(null)
const joinedAt = 'full' in page ? page.full[0]!.joinedAt : -1
cell.setFlags({ admitMode: 'db' })
const seat = { v: 1 as const, userId: 'user-1', relayHostId: identity.hostId, epoch: 9, joinedAt }
expect(cell.relay.sessions.demote(seat)).toBe('off')
const changes = cell.relay.sessions.seatFeed(0)
expect('changes' in changes && changes.changes.at(-1)?.kind).toBe('join')
})
it('ends a demotion with its socket, so the host booked back later is an ordinary seat', async () => {
const cell = await startCell({ admitMode: 'reserve' })
const identity = await cell.host()
cell.book(identity, 9)
const loser = await cell.connect(identity, 9)
const page = cell.relay.sessions.seatFeed(null)
const joinedAt = 'full' in page ? page.full[0]!.joinedAt : -1
const seat = { v: 1 as const, userId: 'user-1', relayHostId: identity.hostId, epoch: 9, joinedAt }
expect(cell.relay.sessions.demote(seat)).toBe('demoted')
// The loser goes on its own, well before the 60 s demotion close.
loser.socket!.close()
await new Promise((resolve) => setTimeout(resolve, 50))
const left = cell.relay.sessions.seatFeed(0)
// Recorded as moved, so neither rule 2 nor a director's sticky sends it back at 9.
expect('changes' in left && left.changes.at(-1)).toMatchObject({ kind: 'leave', closeCode: 4409 })
expect((await cell.connect(identity, 9)).ack).toBeUndefined()
// Booked back onto this cell at a newer epoch: admitted, and then remembered as seated.
expect(cell.book(identity, 11)).toEqual([{ outcome: 'ok' }])
const back = await cell.connect(identity, 11)
expect(back.ack).toMatchObject({ type: 'host-hello-ack' })
back.socket!.close()
await new Promise((resolve) => setTimeout(resolve, 50))
expect((await cell.connect(identity, 11)).ack).toMatchObject({ type: 'host-hello-ack' })
})
it('refuses a booked upgrade at the hard cap and leaves the feed units and bookings as they were', async () => {
const hardCap = 4
const cell = await startCell({ admitMode: 'reserve' }, { hardCap, controlReserve: 1 })
const [waiting, seated] = [await cell.host(), await cell.host()]
expect(cell.book(waiting, 5)[0]?.outcome).toBe('ok')
expect(cell.book(seated, 5)[0]?.outcome).toBe('ok')
expect((await cell.connect(seated, 5)).ack?.type).toBe('host-hello-ack')
// Rebinds over the live control may rise to the hard cap; unproven, they hold their units.
expect(await cell.upgradeOnly(seated)).toBe(101)
expect(await cell.upgradeOnly(seated)).toBe(101)
const before = {
units: cell.relay.connectionSnapshot()!.enforcedConnectionUnits,
bookings: cell.relay.sessions.reserveCounts()!.bookings
}
expect(before).toEqual({ units: hardCap, bookings: 1 })
expect((await cell.connect(waiting, 5)).status).toBe(503)
expect({
units: cell.relay.connectionSnapshot()!.enforcedConnectionUnits,
bookings: cell.relay.sessions.reserveCounts()!.bookings
}).toEqual(before)
}, 30_000)
it('never crosses the hard cap under seeded bookings, hellos, rebooks and closes', async () => {
const hardCap = 6
const cell = await startCell({ admitMode: 'reserve' }, { hardCap, controlReserve: 1 })
const hosts = await Promise.all(Array.from({ length: 12 }, () => cell.host()))
const epochs = new Map<string, number>()
const open: WebSocket[] = []
let seed = 7
const random = () => {
seed = (seed * 1_103_515_245 + 12_345) % 2_147_483_648
return seed / 2_147_483_648
}
let peak = 0
for (let step = 0; step < 240; step += 1) {
const identity = hosts[Math.floor(random() * hosts.length)]!
const roll = random()
if (roll < 0.4) {
// A director's fresh booking, or a re-book at a newer epoch.
const epoch = (epochs.get(identity.hostId) ?? 1) + (random() < 0.5 ? 0 : 1)
if (cell.book(identity, epoch)[0]?.outcome === 'ok') epochs.set(identity.hostId, epoch)
} else if (roll < 0.8) {
const reply = await cell.connect(identity, epochs.get(identity.hostId) ?? 1)
if (reply.ack?.type === 'host-hello-ack') open.push(reply.socket!)
} else if (open.length > 0) {
const socket = open.splice(Math.floor(random() * open.length), 1)[0]!
// A newer control for the same host may already have replaced it.
if (socket.readyState !== WebSocket.CLOSED) {
const closed = new Promise((resolve) => socket.once('close', resolve))
socket.close()
await closed
}
}
const counts = cell.relay.connectionSnapshot()!
peak = Math.max(peak, counts.physicalConnections)
expect(counts.physicalConnections).toBeLessThanOrEqual(hardCap)
expect(counts.enforcedConnectionUnits).toBeLessThanOrEqual(hardCap)
}
// The run did reach the cap, so the bound was exercised.
expect(peak).toBeGreaterThanOrEqual(hardCap - 1)
}, 60_000)
})
@@ -0,0 +1,131 @@
import { describe, expect, it } from 'vitest'
import { CellReserveBook, type BookingCapacity, type ReserveContext } from './cell-reserve-book.js'
function units(limit: number): BookingCapacity & { held: () => number } {
let held = 0
return {
held: () => held,
canReserve: () => held < limit,
tryReserve: () => {
if (held >= limit) return null
held += 1
let released = false
return {
release: () => {
if (released) return
released = true
held -= 1
}
}
}
}
}
const reserveMode: ReserveContext = { mode: 'reserve', draining: false, seatedEpoch: () => undefined }
function item(userId: string, epoch = 2, extra: { sticky?: boolean; ttlMs?: number } = {}) {
return { userId, relayHostId: 'abcdefghijklmnop', epoch, ttlMs: extra.ttlMs ?? 30_000, ...extra }
}
describe('cell reserve book', () => {
it('books up to the capacity and answers full past it', () => {
let now = 0
const capacity = units(2)
const book = new CellReserveBook(capacity, () => 100, () => now)
expect(book.reserve('d1', item('a'), reserveMode)).toEqual({ outcome: 'ok' })
expect(book.reserve('d1', item('b'), reserveMode)).toEqual({ outcome: 'ok' })
expect(book.reserve('d1', item('c'), reserveMode)).toEqual({ outcome: 'full' })
expect(capacity.held()).toBe(2)
now = 31_000
book.sweep()
expect(capacity.held()).toBe(0)
expect(book.count()).toBe(0)
})
it('refuses past the intake budget and refills at the configured rate', () => {
let now = 0
const book = new CellReserveBook(units(1_000), () => 2, () => now, 3)
const outcomes = ['a', 'b', 'c', 'd'].map((user) => book.reserve('d1', item(user), reserveMode))
expect(outcomes.map((outcome) => outcome.outcome)).toEqual(['ok', 'ok', 'ok', 'intake'])
now = 500
expect(book.reserve('d1', item('e'), reserveMode).outcome).toBe('ok')
expect(book.reserve('d1', item('f'), reserveMode).outcome).toBe('intake')
})
it('answers seated-newer when the host is seated or booked at an equal or higher epoch', () => {
const book = new CellReserveBook(units(10), () => 100)
const seatedAt5: ReserveContext = { ...reserveMode, seatedEpoch: () => 5 }
expect(book.reserve('d1', item('a', 5), seatedAt5)).toEqual({ outcome: 'seated-newer', epoch: 5 })
expect(book.reserve('d1', item('b', 3), reserveMode).outcome).toBe('ok')
expect(book.reserve('d2', item('b', 3), reserveMode)).toEqual({ outcome: 'seated-newer', epoch: 3 })
expect(book.reserve('d2', item('b', 4), reserveMode).outcome).toBe('ok')
expect(book.count()).toBe(1)
})
it('lets a sticky re-booking skip the intake bucket but never the cap', () => {
const capacity = units(1)
const book = new CellReserveBook(capacity, () => 0, () => 0, 1)
expect(book.reserve('d1', item('a'), reserveMode).outcome).toBe('ok')
expect(book.reserve('d1', item('b'), reserveMode).outcome).toBe('full')
expect(book.reserve('d1', item('c', 2, { sticky: true }), reserveMode).outcome).toBe('full')
expect(book.take('a', 'abcdefghijklmnop', 2)).not.toBeNull()
expect(book.reserve('d1', item('c', 2, { sticky: true }), reserveMode).outcome).toBe('ok')
})
it('answers ok for a sticky re-booking of a host already seated at that epoch, booking nothing', () => {
const capacity = units(1)
const book = new CellReserveBook(capacity, () => 0, () => 0, 0)
const seated: ReserveContext = { ...reserveMode, seatedEpoch: () => 4 }
expect(book.reserve('d1', item('a', 4, { sticky: true }), seated).outcome).toBe('ok')
expect(capacity.held()).toBe(0)
})
it('consumes a booking once, only at its own epoch, and releases its unit', () => {
const capacity = units(5)
const book = new CellReserveBook(capacity, () => 100)
book.reserve('d7', item('a', 9), reserveMode)
expect(book.take('a', 'abcdefghijklmnop', 8)).toBeNull()
expect(book.take('a', 'abcdefghijklmnop', 9)).toMatchObject({ epoch: 9, directorId: 'd7' })
expect(book.take('a', 'abcdefghijklmnop', 9)).toBeNull()
expect(capacity.held()).toBe(0)
})
it('answers off outside reserve mode, draining while draining, and dry runs book nothing', () => {
const capacity = units(5)
const book = new CellReserveBook(capacity, () => 100)
const databaseMode: ReserveContext = { ...reserveMode, mode: 'db' }
expect(book.reserve('d1', item('a'), databaseMode).outcome).toBe('off')
expect(book.reserve('d1', item('a'), databaseMode, true).outcome).toBe('ok')
expect(book.reserve('d1', item('a'), { ...reserveMode, draining: true }).outcome).toBe('draining')
expect(capacity.held()).toBe(0)
expect(book.count()).toBe(0)
})
it('hands a lower-epoch booking unit to its successor rather than taking a second', () => {
const capacity = units(1)
const book = new CellReserveBook(capacity, () => 100)
expect(book.reserve('d1', item('a', 2), reserveMode).outcome).toBe('ok')
expect(book.reserve('d2', item('a', 3), reserveMode).outcome).toBe('ok')
expect(capacity.held()).toBe(1)
book.clear()
expect(capacity.held()).toBe(0)
})
it('hands a booking unit to the host upgrade, so the host counts once until its hello', () => {
const capacity = units(2)
const book = new CellReserveBook(capacity, () => 100)
book.reserve('d1', item('a', 2), reserveMode)
expect(capacity.held()).toBe(1)
book.handOff('a', 'abcdefghijklmnop')
book.handOff('a', 'abcdefghijklmnop')
expect(capacity.held()).toBe(0)
// Still a booking for the hello, at its own epoch only.
expect(book.has('a', 'abcdefghijklmnop')).toBe(true)
expect(book.take('a', 'abcdefghijklmnop', 3)).toBeNull()
// A newer booking for a handed-off host needs a unit of its own.
expect(book.reserve('d1', item('a', 3), reserveMode).outcome).toBe('ok')
expect(capacity.held()).toBe(1)
expect(book.take('a', 'abcdefghijklmnop', 3)).toMatchObject({ epoch: 3 })
expect(capacity.held()).toBe(0)
})
})
+178
View File
@@ -0,0 +1,178 @@
import {
CELL_INTAKE_BURST,
type ReserveItem,
type ReserveOutcome
} from './cell-reserve-contract.js'
// The cell's half of step 5: it alone says yes or no to each booking. One event loop makes
// "check cap and budget, then count the booking" atomic with no lock.
// A connection unit held for a booked host until its hello consumes it or the TTL ends.
export type BookingUnit = { release(): void }
export type BookingCapacity = {
// Null when one more unit would cross the cell's placement ceiling.
tryReserve(): BookingUnit | null
canReserve(): boolean
}
export type CellAdmitMode = 'db' | 'reserve'
export type ReserveContext = {
mode: CellAdmitMode
draining: boolean
// The epoch this host is seated at here, if it is.
seatedEpoch: (userId: string, relayHostId: string) => number | undefined
}
export type CellBooking = {
userId: string
relayHostId: string
epoch: number
directorId: string
expiresAt: number
}
// `handedOff`: the host's upgrade now counts this unit, so the booking holds none.
type HeldBooking = CellBooking & { unit: BookingUnit; handedOff: boolean }
function hostKey(userId: string, relayHostId: string): string {
return `${userId}\u0000${relayHostId}`
}
export class IntakeBucket {
private tokens: number
private refilledAt: number
constructor(
private readonly ratePerSec: () => number,
private readonly burst: number,
now: number
) {
this.tokens = burst
this.refilledAt = now
}
available(now: number): number {
const elapsedMs = Math.max(0, now - this.refilledAt)
this.tokens = Math.min(this.burst, this.tokens + (elapsedMs / 1_000) * this.ratePerSec())
this.refilledAt = now
return this.tokens
}
take(now: number): boolean {
if (this.available(now) < 1) return false
this.tokens -= 1
return true
}
}
export class CellReserveBook {
private readonly bookings = new Map<string, HeldBooking>()
private readonly intake: IntakeBucket
constructor(
private readonly capacity: BookingCapacity,
private readonly intakePerSec: () => number,
private readonly now: () => number = Date.now,
private readonly burst = CELL_INTAKE_BURST
) {
this.intake = new IntakeBucket(intakePerSec, burst, now())
}
reserve(
directorId: string,
item: ReserveItem,
context: ReserveContext,
dryRun = false
): ReserveOutcome {
const now = this.now()
this.sweep(now)
if (context.mode !== 'reserve' && !dryRun) return { outcome: 'off' }
if (context.draining) return { outcome: 'draining' }
const key = hostKey(item.userId, item.relayHostId)
const seated = context.seatedEpoch(item.userId, item.relayHostId)
const booked = this.bookings.get(key)
const known = Math.max(seated ?? 0, booked?.epoch ?? 0)
if (item.sticky) {
if (known > item.epoch) return { outcome: 'seated-newer', epoch: known }
if (seated === item.epoch) return { outcome: 'ok' }
if (booked?.epoch === item.epoch) {
if (!dryRun) booked.expiresAt = Math.max(booked.expiresAt, now + item.ttlMs)
return { outcome: 'ok' }
}
} else if (known >= item.epoch) {
return { outcome: 'seated-newer', epoch: known }
}
// A lower-epoch booking for this host hands its unit to the new one.
const heldUnit = booked && !booked.handedOff ? booked.unit : undefined
if (!heldUnit && !this.capacity.canReserve()) return { outcome: 'full' }
// The post-restart sticky re-booking books nothing new, so it takes no token.
if (!item.sticky && this.intake.available(now) < 1) return { outcome: 'intake' }
if (dryRun) return { outcome: 'ok' }
const unit = heldUnit ?? this.capacity.tryReserve()
if (!unit) return { outcome: 'full' }
if (!item.sticky) this.intake.take(now)
this.bookings.set(key, {
userId: item.userId,
relayHostId: item.relayHostId,
epoch: item.epoch,
directorId,
expiresAt: now + item.ttlMs,
unit,
handedOff: false
})
return { outcome: 'ok' }
}
has(userId: string, relayHostId: string): boolean {
const booking = this.bookings.get(hostKey(userId, relayHostId))
return booking !== undefined && booking.expiresAt > this.now()
}
// The booked host's upgrade takes over its unit, so the host counts once until its hello.
// The upgrade carries no epoch; the hello's take() still requires the booked one.
handOff(userId: string, relayHostId: string): void {
const booking = this.bookings.get(hostKey(userId, relayHostId))
if (!booking || booking.handedOff || booking.expiresAt <= this.now()) return
booking.handedOff = true
booking.unit.release()
}
// The hello's rule 1: a booking for this host at exactly this epoch admits it once.
take(userId: string, relayHostId: string, epoch: number): CellBooking | null {
const key = hostKey(userId, relayHostId)
const booking = this.bookings.get(key)
if (!booking || booking.epoch !== epoch || booking.expiresAt <= this.now()) return null
this.bookings.delete(key)
booking.unit.release()
const { unit: _unit, ...held } = booking
return held
}
// Leaving reserve mode, or the cell starting to drain, voids every booking.
clear(): void {
for (const booking of this.bookings.values()) booking.unit.release()
this.bookings.clear()
}
sweep(now = this.now()): void {
for (const [key, booking] of this.bookings) {
if (booking.expiresAt > now) continue
this.bookings.delete(key)
booking.unit.release()
}
}
count(): number {
return this.bookings.size
}
intakeSnapshot(): { perSec: number; burst: number; tokens: number } {
return {
perSec: this.intakePerSec(),
burst: this.burst,
tokens: Math.floor(this.intake.available(this.now()) * 100) / 100
}
}
}
@@ -0,0 +1,55 @@
import { describe, expect, it, vi } from 'vitest'
import { CellReserveClient } from './cell-reserve-client.js'
const target = { cellId: 'c1', cellUrl: 'https://c1.example' }
const item = (userId: string, epoch = 3) => ({
userId,
relayHostId: 'abcdefghijklmnop',
epoch,
ttlMs: 30_000
})
describe('cell reserve client', () => {
it('batches a cell’s bookings into one request and maps each answer back', async () => {
const fetchImpl = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => {
const body = JSON.parse(String(init?.body)) as { items: Array<{ userId: string }> }
return Response.json({
v: 1,
results: body.items.map((entry) =>
entry.userId === 'b' ? { outcome: 'seated-newer', epoch: 9 } : { outcome: 'ok' }
)
})
})
const client = new CellReserveClient({
directorId: 'director-a',
identityToken: async () => 'token',
fetch: fetchImpl
})
const answers = await Promise.all([
client.reserve(target, item('a')),
client.reserve(target, item('b')),
client.reserve(target, item('c'), true)
])
expect(answers).toEqual([{ outcome: 'ok' }, { outcome: 'seated-newer', epoch: 9 }, { outcome: 'ok' }])
// One booking batch and one dry-run batch.
expect(fetchImpl).toHaveBeenCalledTimes(2)
const first = JSON.parse(String(fetchImpl.mock.calls[0]![1]?.body)) as Record<string, unknown>
expect(first).toMatchObject({ v: 1, directorId: 'director-a', items: [{ userId: 'a' }, { userId: 'b' }] })
expect(String(fetchImpl.mock.calls[0]![0])).toBe('https://c1.example/internal/reserve')
})
it('reads every item as unreachable when the cell fails or answers out of shape', async () => {
for (const fetchImpl of [
async () => Promise.reject(new Error('timeout')),
async () => new Response('nope', { status: 503 }),
async () => Response.json({ v: 1, results: [] })
]) {
const client = new CellReserveClient({
directorId: 'director-a',
identityToken: async () => 'token',
fetch: fetchImpl
})
expect(await client.reserve(target, item('a'))).toEqual({ outcome: 'unreachable' })
}
})
})
+108
View File
@@ -0,0 +1,108 @@
import {
CELL_DEMOTE_PATH,
CELL_RESERVE_MAX_ITEMS,
CELL_RESERVE_PATH,
ReserveResponseSchema,
type DemoteRequest,
type ReserveItem
} from './cell-reserve-contract.js'
import type { ReserveAttempt } from './reserve-placement.js'
// The director's calls to cells. Bookings for one cell batch for up to 5 ms or 64 items;
// a cell that does not answer within the timeout is unverifiable, and every item in the
// batch reads `unreachable` (one of the request's three tries).
export const CELL_RESERVE_FLUSH_MS = 5
export const CELL_RESERVE_TIMEOUT_MS = 1_000
export type ReserveTarget = { cellId: string; cellUrl: string }
type Pending = { item: ReserveItem; resolve: (attempt: ReserveAttempt) => void }
export class CellReserveClient {
private readonly batches = new Map<string, { target: ReserveTarget; dryRun: boolean; items: Pending[] }>()
constructor(
private readonly input: {
directorId: string
identityToken: () => Promise<string>
fetch?: typeof fetch
flushMs?: number
timeoutMs?: number
}
) {}
reserve(target: ReserveTarget, item: ReserveItem, dryRun = false): Promise<ReserveAttempt> {
const key = `${target.cellId}\u0000${dryRun ? 'dry' : 'book'}`
return new Promise((resolve) => {
let batch = this.batches.get(key)
if (!batch) {
batch = { target, dryRun, items: [] }
this.batches.set(key, batch)
const timer = setTimeout(() => this.flush(key), this.input.flushMs ?? CELL_RESERVE_FLUSH_MS)
timer.unref?.()
}
batch.items.push({ item, resolve })
if (batch.items.length >= CELL_RESERVE_MAX_ITEMS) this.flush(key)
})
}
// The cell treats repeats as one; a failure is only logged, and the next poll retries.
async demote(target: ReserveTarget, request: DemoteRequest): Promise<boolean> {
try {
const response = await this.post(target, CELL_DEMOTE_PATH, request)
await response.body?.cancel().catch(() => undefined)
return response.ok
} catch {
return false
}
}
private flush(key: string): void {
const batch = this.batches.get(key)
if (!batch) return
this.batches.delete(key)
void this.send(batch.target, batch.dryRun, batch.items)
}
private async send(target: ReserveTarget, dryRun: boolean, items: Pending[]): Promise<void> {
let results: Array<{ outcome: string; epoch?: number }> | null = null
try {
const response = await this.post(target, CELL_RESERVE_PATH, {
v: 1,
directorId: this.input.directorId,
...(dryRun ? { dryRun: true } : {}),
items: items.map((pending) => pending.item)
})
if (response.ok) {
const body = ReserveResponseSchema.safeParse(await response.json().catch(() => null))
if (body.success && body.data.results.length === items.length) results = body.data.results
} else {
await response.body?.cancel().catch(() => undefined)
}
} catch {
results = null
}
items.forEach((pending, index) => {
const result = results?.[index]
pending.resolve(
!result
? { outcome: 'unreachable' }
: result.outcome === 'seated-newer' && typeof result.epoch === 'number'
? { outcome: 'seated-newer', epoch: result.epoch }
: { outcome: result.outcome }
)
})
}
private async post(target: ReserveTarget, path: string, body: unknown): Promise<Response> {
return await (this.input.fetch ?? fetch)(new URL(path, target.cellUrl), {
method: 'POST',
headers: {
authorization: `Bearer ${await this.input.identityToken()}`,
'content-type': 'application/json'
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(this.input.timeoutMs ?? CELL_RESERVE_TIMEOUT_MS)
})
}
}
@@ -0,0 +1,58 @@
import { z } from 'zod'
// Step 5 wire between directors and cells (v1). Directors book a seat on a cell before
// answering a desktop; the cell admits or refuses each booking itself. Readers ignore
// unknown fields, so either side may add optional ones.
export const CELL_RESERVE_PATH = '/internal/reserve'
export const CELL_DEMOTE_PATH = '/internal/demote'
export const CELL_RESERVE_MAX_ITEMS = 64
export const CELL_RESERVE_TTL_MS = 30_000
export const CELL_INTAKE_BURST = 20
export const ReserveItemSchema = z.object({
userId: z.string().min(1).max(256),
relayHostId: z.string().regex(/^[A-Za-z0-9_-]{16}$/),
epoch: z.number().int().positive(),
ttlMs: z.number().int().positive().max(CELL_RESERVE_TTL_MS),
// The re-booking of a host seated here before this cell restarted: no intake token.
sticky: z.boolean().optional()
})
export type ReserveItem = z.infer<typeof ReserveItemSchema>
export const ReserveRequestSchema = z.object({
v: z.literal(1),
directorId: z.string().regex(/^[A-Za-z0-9_-]{1,64}$/),
// Checks every rule and books nothing (the shadow gate measures this before any flip).
dryRun: z.boolean().optional(),
items: z.array(ReserveItemSchema).min(1).max(CELL_RESERVE_MAX_ITEMS)
})
export type ReserveRequest = z.infer<typeof ReserveRequestSchema>
// `off`: the cell is not in reserve mode, so the director must use the database path.
export type ReserveOutcome =
| { outcome: 'ok' }
| { outcome: 'full' }
| { outcome: 'intake' }
| { outcome: 'draining' }
| { outcome: 'off' }
| { outcome: 'seated-newer'; epoch: number }
// Lenient: a newer cell may answer an outcome this director does not know; it counts as a no.
export const ReserveResponseSchema = z.object({
v: z.literal(1),
results: z.array(
z.object({ outcome: z.string(), epoch: z.number().int().optional() }).passthrough()
)
})
// Names one seat exactly: the epoch alone would also match the host rejoining this cell later.
export const DemoteRequestSchema = z.object({
v: z.literal(1),
userId: z.string().min(1).max(256),
relayHostId: z.string().regex(/^[A-Za-z0-9_-]{16}$/),
epoch: z.number().int().positive(),
joinedAt: z.number().int().nonnegative()
})
export type DemoteRequest = z.infer<typeof DemoteRequestSchema>
@@ -0,0 +1,64 @@
import { createHash } from 'node:crypto'
import type { CellFlags } from './cell-flags.js'
import type { AppliedControlFlags } from './relay-control-flag-channel.js'
// A reserve-mode cell keeps its hosts in memory only while a placing director is there to
// book and supersede them. With none for this long (a director rolled back, or placement
// turned off) it flips itself back to db and re-registers, as a switch flip would.
export const RESERVE_DEAD_MAN_MS = 60_000
const RESERVE_DEAD_MAN_SPREAD_MS = 60_000
// 60-120 s, fixed per cell: when every director goes at once, the cells trip (and start
// re-registering against the database) spread over a minute, not together.
export function reserveDeadManWindowMs(cellId: string): number {
const digest = createHash('sha256').update(cellId).digest()
return RESERVE_DEAD_MAN_MS + (digest.readUInt32BE(0) % (RESERVE_DEAD_MAN_SPREAD_MS + 1))
}
export class ReserveDeadMan {
private lastContactAt: number
private reserveGeneration: number | null = null
private trippedGeneration: number | null = null
constructor(
private readonly cellId: string,
private readonly now: () => number = Date.now,
private readonly windowMs = RESERVE_DEAD_MAN_MS,
private readonly log: (line: string) => void = (line) => console.error(line)
) {
this.lastContactAt = now()
}
// A feed poll marked reserver=1, or a real (not dry-run) booking.
contact(): void {
this.lastContactAt = this.now()
}
// The admitMode the cell acts on. Latched per switch-file generation: once tripped, only a
// new write brings reserve back, so a director that returns cannot flap the cell.
mode(applied: AppliedControlFlags<CellFlags>): 'db' | 'reserve' {
if (applied.flags.admitMode !== 'reserve') {
this.reserveGeneration = null
return 'db'
}
const now = this.now()
if (this.reserveGeneration !== applied.generation) {
// A new reserve write starts its own window.
this.reserveGeneration = applied.generation
this.trippedGeneration = null
this.lastContactAt = Math.max(this.lastContactAt, now)
}
if (this.trippedGeneration === applied.generation) return 'db'
if (now - this.lastContactAt <= this.windowMs) return 'reserve'
this.trippedGeneration = applied.generation
this.log(
JSON.stringify({
event: 'orca_relay_cell_reserve_dead_man_tripped',
cellId: this.cellId,
generation: applied.generation,
silentMs: now - this.lastContactAt
})
)
return 'db'
}
}
@@ -0,0 +1,116 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import WebSocket from 'ws'
import { openRelayDatabase, type RelayDatabase } from './database.js'
import { openDelayedPostgresDatabase, type StatementDelay } from './test-fixtures/delayed-postgres-database.js'
import { RESERVE_MODE_CELL_ID, startReserveModeCell } from './test-fixtures/reserve-mode-cell.js'
// E-mix on PostgreSQL: a reserve-mode cell flipped back to db leases every control it admitted
// from memory, disconnecting nobody, with the pool mostly left to DB-path work and one
// relay_cells write per tick, even with a cross-region round trip on every statement.
const databaseUrl = process.env.ORCA_RELAY_TEST_POSTGRES_URL
const describePostgres = databaseUrl ? describe : describe.skip
async function until(check: () => Promise<boolean> | boolean, timeoutMs: number): Promise<void> {
const deadline = Date.now() + timeoutMs
while (!(await check())) {
if (Date.now() > deadline) throw new Error('condition not reached')
await new Promise((resolve) => setTimeout(resolve, 50))
}
}
describePostgres('flipping a reserve-mode cell back on PostgreSQL', () => {
const cleanup: Array<() => Promise<void> | void> = []
afterEach(async () => {
for (const close of cleanup.splice(0).reverse()) await close()
vi.restoreAllMocks()
// The database is shared with the other PostgreSQL suites, some of which read every cell.
const database = await openRelayDatabase({ databaseUrl, dataDir: tmpdir() })
try {
const tables = await database.query(
`SELECT table_name FROM information_schema.columns
WHERE table_schema = current_schema() AND column_name = 'cell_id' AND table_name LIKE 'relay_%'`
)
for (const row of tables) {
await database.query(`DELETE FROM ${String(row.table_name)} WHERE cell_id = ?`, [RESERVE_MODE_CELL_ID])
}
} finally {
await database.close()
}
})
async function cellOn(database: RelayDatabase, databasePoolMax = 10) {
vi.spyOn(console, 'log').mockImplementation(() => undefined)
vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const dataDir = mkdtempSync(join(tmpdir(), 'orca-relay-flip-back-pg-'))
cleanup.push(() => rmSync(dataDir, { recursive: true, force: true }))
return await startReserveModeCell({ database, dataDir, cleanup, databasePoolMax })
}
it('re-registers every memory-admitted control, ten flips in a row', async () => {
const database = await openRelayDatabase({ databaseUrl, dataDir: tmpdir() })
cleanup.push(() => database.close())
for (let flip = 0; flip < 10; flip += 1) {
const cell = await cellOn(database)
const hosts = []
for (let index = 0; index < 3; index += 1) hosts.push(await cell.bookedHost(5 + index))
const ids = new Set(hosts.map((entry) => entry.identity.hostId))
const leased = async () =>
(await cell.controlLeases()).filter((row) => ids.has(String(row.relay_host_id)))
expect(await leased()).toEqual([])
cell.setAdmitMode('db')
await until(async () => (await leased()).length === 3, 10_000)
await until(() => cell.relay.sessions.reregistrationPending() === 0, 10_000)
expect(hosts.every((entry) => entry.socket.readyState === WebSocket.OPEN)).toBe(true)
for (const entry of hosts) entry.socket.close()
}
}, 120_000)
it('keeps re-registration to a third of the pool and one cell-row write per tick, at 170 ms a trip', async () => {
const delay: StatementDelay = { enabled: false, delayMs: 170 }
let cellRowWrites = 0
let leaseWritesInFlight = 0
let peakInFlight = 0
delay.beforeTrip = async (sql) => {
if (/UPDATE relay_cells\b/.test(sql)) cellRowWrites += 1
}
const database = openDelayedPostgresDatabase(databaseUrl!, delay, 10)
cleanup.push(() => database.close())
// The schema and the cell row, without the delay.
const setup = await openRelayDatabase({ databaseUrl, dataDir: tmpdir() })
await setup.close()
const cell = await cellOn(database, 10)
const deferred = cell.relay.assignments.activateControlDeferringCell.bind(cell.relay.assignments)
vi.spyOn(cell.relay.assignments, 'activateControlDeferringCell').mockImplementation(async (...args) => {
leaseWritesInFlight += 1
peakInFlight = Math.max(peakInFlight, leaseWritesInFlight)
try {
return await deferred(...args)
} finally {
leaseWritesInFlight -= 1
}
})
const hosts = []
for (let index = 0; index < 9; index += 1) hosts.push(await cell.bookedHost(5 + index))
const ids = new Set(hosts.map((entry) => entry.identity.hostId))
delay.enabled = true
const startedAt = Date.now()
cell.setAdmitMode('db')
await until(() => cell.relay.sessions.reregistrationPending() > 0, 5_000)
await until(() => cell.relay.sessions.reregistrationPending() === 0, 45_000)
const elapsedMs = Date.now() - startedAt
delay.enabled = false
const leased = (await cell.controlLeases()).filter((row) => ids.has(String(row.relay_host_id)))
expect(leased).toHaveLength(9)
expect(hosts.every((entry) => entry.socket.readyState === WebSocket.OPEN)).toBe(true)
// Pool of 10: at most 3 activations at once, leaving 7 for hellos and renewals.
expect(peakInFlight).toBeLessThanOrEqual(3)
// Far fewer cell-row writes than activations: deltas fold into one write per tick.
expect(cellRowWrites).toBeLessThan(9)
process.stderr.write(`flip_back_latency ${JSON.stringify({ elapsedMs, peakInFlight, cellRowWrites })}\n`)
}, 60_000)
})
@@ -0,0 +1,295 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import WebSocket from 'ws'
import { CELL_ADMIT_EFFECTIVE_REFRESH_MS } from './cell-admit-effective-writer.js'
import { RESERVE_DEAD_MAN_MS, reserveDeadManWindowMs } from './cell-reserve-dead-man.js'
import { openRelayDatabase } from './database.js'
import {
REREGISTER_MAX_ATTEMPTS,
REREGISTER_STALL_LOG_EVERY,
reregisterInFlightLimit
} from './host-session-registry.js'
import { startReserveModeCell } from './test-fixtures/reserve-mode-cell.js'
// Flip back (E-mix): a cell leaves reserve mode with hosts it admitted from memory. Nobody is
// disconnected, and the database ends up holding exactly the leases its seat report names.
const byHost = (left: Record<string, unknown>, right: Record<string, unknown>) =>
String(left.relay_host_id) < String(right.relay_host_id) ? -1 : 1
async function until(check: () => Promise<boolean> | boolean, timeoutMs = 10_000): Promise<void> {
const deadline = Date.now() + timeoutMs
while (!(await check())) {
if (Date.now() > deadline) throw new Error('condition not reached')
await new Promise((resolve) => setTimeout(resolve, 50))
}
}
describe('flipping a reserve-mode cell back to the database', () => {
const cleanup: Array<() => Promise<void> | void> = []
afterEach(async () => {
for (const close of cleanup.splice(0).reverse()) await close()
vi.restoreAllMocks()
})
async function cell(now?: () => number, options: { flagsUnread?: boolean } = {}) {
vi.spyOn(console, 'log').mockImplementation(() => undefined)
vi.spyOn(console, 'warn').mockImplementation(() => undefined)
vi.spyOn(console, 'error').mockImplementation(() => undefined)
const dataDir = mkdtempSync(join(tmpdir(), 'orca-relay-reserve-rollback-'))
cleanup.push(() => rmSync(dataDir, { recursive: true, force: true }))
const database = await openRelayDatabase({ dataDir })
cleanup.push(() => database.close())
return await startReserveModeCell({ database, dataDir, cleanup, ...options, ...(now ? { now } : {}) })
}
it('registers every memory-admitted control without disconnecting any, matching the seat report', async () => {
const reserve = await cell()
const first = await reserve.bookedHost(5)
const second = await reserve.bookedHost(6)
// The ledger lags by up to a second: this row lands only after the flip.
const late = await reserve.bookedHost(7, { row: 'never' })
expect(await reserve.controlLeases()).toEqual([])
const marked = vi.spyOn(reserve.relay.assignments, 'markMigrationTargetRegistered')
reserve.setAdmitMode('db')
await until(async () => (await reserve.controlLeases()).length === 2)
// Still reporting reserve: sweeps must not resume while a control lacks its lease.
expect(reserve.relay.sessions.reregistrationPending()).toBeGreaterThan(0)
await reserve.insertRow(late.identity.hostId, 7)
await until(async () => (await reserve.controlLeases()).length === 3)
await until(() => reserve.relay.sessions.reregistrationPending() === 0)
expect([first, second, late].every((entry) => entry.socket.readyState === WebSocket.OPEN)).toBe(true)
const page = reserve.relay.sessions.seatFeed(null)
const seats = 'full' in page ? page.full : []
expect(
seats
.map((seat) => ({
relay_host_id: seat.relayHostId,
cell_id: 'production-gce-c3',
activity_id: `control:production-gce-c3:${seat.generation}`
}))
.sort(byHost)
).toEqual((await reserve.controlLeases()).sort(byHost))
// As a database-path join does, each one also tells an open migration its host arrived.
expect(marked.mock.calls.map(([, input]) => input.assignmentEpoch).sort()).toEqual([5, 6, 7])
// A second flip back has nothing left to register.
expect(reserve.relay.sessions.reregisterMemoryControls()).toBe(0)
}, 30_000)
it('closes with 4409 a control whose row never names this cell, after every retry', async () => {
let now = Date.now()
const reserve = await cell(() => now)
// The database says another cell owns it: the lease can never be taken here.
const stranded = await reserve.bookedHost(5, { row: 'production-gce-c9' })
const fine = await reserve.bookedHost(6)
const tries = vi.spyOn(reserve.relay.assignments, 'activateControlDeferringCell')
reserve.setAdmitMode('db')
for (let step = 0; step < 3 * REREGISTER_MAX_ATTEMPTS && stranded.socket.readyState === WebSocket.OPEN; step += 1) {
now += 6_000
// Both desktops stay alive on the test clock: a pong resets the control silence timer.
for (const entry of [stranded, fine]) {
if (entry.socket.readyState === WebSocket.OPEN) entry.socket.send(JSON.stringify({ type: 'pong', t: now }))
}
await new Promise((resolve) => setTimeout(resolve, 120))
}
expect(await stranded.closeCode).toBe(4409)
// Every retry was spent first: the row may still have been on its way.
expect(tries.mock.calls.length).toBeGreaterThanOrEqual(REREGISTER_MAX_ATTEMPTS + 1)
await until(() => reserve.relay.sessions.reregistrationPending() === 0)
expect(fine.socket.readyState).toBe(WebSocket.OPEN)
expect((await reserve.controlLeases()).map((row) => row.relay_host_id)).toEqual([fine.identity.hostId])
}, 30_000)
it('gives up on a refusal from a database that answered, as on a row naming another seat', async () => {
let now = Date.now()
const reserve = await cell(() => now)
const host = await reserve.bookedHost(5)
vi.spyOn(reserve.relay.assignments, 'activateControlDeferringCell').mockRejectedValue(
new Error('activity_lease_shape_mismatch')
)
reserve.setAdmitMode('db')
for (let step = 0; step < 3 * REREGISTER_MAX_ATTEMPTS && host.socket.readyState === WebSocket.OPEN; step += 1) {
now += 6_000
if (host.socket.readyState === WebSocket.OPEN) host.socket.send(JSON.stringify({ type: 'pong', t: now }))
await new Promise((resolve) => setTimeout(resolve, 120))
}
expect(await host.closeCode).toBe(4409)
// The cell stops reporting reserve once nothing is left to re-register.
await until(() => reserve.relay.sessions.reregistrationPending() === 0)
expect(reserve.relay.sessions.inReserveMode()).toBe(false)
}, 30_000)
it('retries a stalled database for as long as the control is open, and closes nobody for it', async () => {
let now = Date.now()
const reserve = await cell(() => now)
const host = await reserve.bookedHost(5)
const real = reserve.relay.assignments.activateControlDeferringCell.bind(reserve.relay.assignments)
let stalls = 0
// Far more failures than the give-up count, as in a long fleet-wide database stall.
vi.spyOn(reserve.relay.assignments, 'activateControlDeferringCell').mockImplementation(async (...args) => {
if (stalls < 3 * REREGISTER_MAX_ATTEMPTS) {
stalls += 1
// A dropped connection: the database never answered.
throw Object.assign(new Error('terminating connection due to administrator command'), { code: '57P01' })
}
return await real(...args)
})
reserve.setAdmitMode('db')
for (let step = 0; step < 6 * REREGISTER_MAX_ATTEMPTS && (await reserve.controlLeases()).length === 0; step += 1) {
now += 6_000
if (host.socket.readyState === WebSocket.OPEN) host.socket.send(JSON.stringify({ type: 'pong', t: now }))
await new Promise((resolve) => setTimeout(resolve, 120))
}
expect(stalls).toBe(3 * REREGISTER_MAX_ATTEMPTS)
expect(await reserve.controlLeases()).toHaveLength(1)
expect(host.socket.readyState).toBe(WebSocket.OPEN)
// The endless retry is visible: one line per run of consecutive stalls.
const stalledLines = vi
.mocked(console.warn)
.mock.calls.filter(([line]) => String(line).includes('orca_relay_cell_reregistration_stalled'))
expect(stalledLines).toHaveLength(Math.floor(stalls / REREGISTER_STALL_LOG_EVERY))
}, 60_000)
it('retries a lease insert that lost a race (23505) instead of counting it as a refusal', async () => {
let now = Date.now()
const reserve = await cell(() => now)
const host = await reserve.bookedHost(5)
const real = reserve.relay.assignments.activateControlDeferringCell.bind(reserve.relay.assignments)
let races = 0
vi.spyOn(reserve.relay.assignments, 'activateControlDeferringCell').mockImplementation(async (...args) => {
if (races < 2 * REREGISTER_MAX_ATTEMPTS) {
races += 1
throw Object.assign(new Error('duplicate key value violates unique constraint'), { code: '23505' })
}
return await real(...args)
})
reserve.setAdmitMode('db')
for (let step = 0; step < 5 * REREGISTER_MAX_ATTEMPTS && (await reserve.controlLeases()).length === 0; step += 1) {
now += 6_000
if (host.socket.readyState === WebSocket.OPEN) host.socket.send(JSON.stringify({ type: 'pong', t: now }))
await new Promise((resolve) => setTimeout(resolve, 120))
}
expect(races).toBe(2 * REREGISTER_MAX_ATTEMPTS)
expect(await reserve.controlLeases()).toHaveLength(1)
expect(host.socket.readyState).toBe(WebSocket.OPEN)
}, 60_000)
it('never lets the switch file take the connections hellos and renewals need', () => {
expect(reregisterInFlightLimit(undefined, 10)).toBe(3)
expect(reregisterInFlightLimit(undefined, 16)).toBe(5)
expect(reregisterInFlightLimit(16, 10)).toBe(8)
expect(reregisterInFlightLimit(4, 10)).toBe(4)
expect(reregisterInFlightLimit(16, 2)).toBe(1)
})
it('takes its flip-back pace from the switch file', async () => {
const reserve = await cell()
for (let index = 0; index < 4; index += 1) await reserve.bookedHost(5 + index)
const real = reserve.relay.assignments.activateControlDeferringCell.bind(reserve.relay.assignments)
let inFlight = 0
let peak = 0
vi.spyOn(reserve.relay.assignments, 'activateControlDeferringCell').mockImplementation(async (...args) => {
inFlight += 1
peak = Math.max(peak, inFlight)
await new Promise((resolve) => setTimeout(resolve, 50))
try {
return await real(...args)
} finally {
inFlight -= 1
}
})
reserve.setAdmitMode('db', { reregisterInFlight: 1 })
await until(async () => (await reserve.controlLeases()).length === 4)
expect(peak).toBe(1)
}, 30_000)
it('leases a control whose proof lands after the flip, though memory admitted its hello', async () => {
const reserve = await cell()
const identity = await reserve.host()
await reserve.insertRow(identity.hostId, 4)
reserve.relay.sessions.reserve({
v: 1,
directorId: 'director-a',
items: [{ userId: 'user-1', relayHostId: identity.hostId, epoch: 4, ttlMs: 30_000 }]
})
// The flip lands between the hello (admitted from the booking) and the proof.
const activate = vi.spyOn(reserve.relay.assignments, 'activateControl')
const pending = reserve.connect(identity, 4)
reserve.setAdmitMode('db')
const reply = await pending
expect(reply.ack).toMatchObject({ type: 'host-hello-ack' })
await until(async () => (await reserve.controlLeases()).length === 1)
// The proof took the lease itself, or the rescan did: either way exactly one.
expect(activate.mock.calls.length + reserve.relay.sessions.reregistrationPending()).toBeLessThanOrEqual(1)
expect(reply.socket.readyState).toBe(WebSocket.OPEN)
}, 30_000)
// Step 5 off stays write-free: a db switch (read, generation > 0) writes no row, nor does a
// booted cell before its first flag read. Leaving reserve writes db once, then nothing.
it('writes an admit-effective row only while its switch says reserve, plus one db write on the way out', async () => {
let now = Date.now()
const booting = await cell(() => now, { flagsUnread: true })
await new Promise((resolve) => setTimeout(resolve, 2_500))
expect(await booting.admitEffectiveRow()).toBeUndefined()
booting.setAdmitMode('db')
now += 3 * CELL_ADMIT_EFFECTIVE_REFRESH_MS
await new Promise((resolve) => setTimeout(resolve, 2_500))
expect(await booting.admitEffectiveRow()).toBeUndefined()
booting.setAdmitMode('reserve')
await until(async () => (await booting.admitEffectiveRow())?.mode === 'reserve')
booting.setAdmitMode('db')
await until(async () => (await booting.admitEffectiveRow())?.mode === 'db')
const last = (await booting.admitEffectiveRow())!.updated_at
now += 3 * CELL_ADMIT_EFFECTIVE_REFRESH_MS
await new Promise((resolve) => setTimeout(resolve, 2_500))
expect((await booting.admitEffectiveRow())!.updated_at).toEqual(last)
}, 30_000)
it('flips itself back to db when no placing director has been in touch for the dead-man window', async () => {
let now = Date.now()
const reserve = await cell(() => now)
const seated = await reserve.bookedHost(3)
expect(reserve.relay.sessions.inReserveMode()).toBe(true)
await until(async () => (await reserve.admitEffectiveRow())?.mode === 'reserve')
const windowMs = reserveDeadManWindowMs(reserve.config.cellId)
expect(windowMs).toBeGreaterThanOrEqual(RESERVE_DEAD_MAN_MS)
expect(windowMs).toBeLessThanOrEqual(2 * RESERVE_DEAD_MAN_MS)
// Cells trip at different times, so a fleet-wide trip is staggered.
const windows = new Set(['c1', 'c2', 'c3', 'c4', 'c5'].map((cell) => reserveDeadManWindowMs(`production-gce-${cell}`)))
expect(windows.size).toBeGreaterThan(1)
// A booking is contact: the window restarts.
now += windowMs - 1_000
await reserve.bookedHost(4)
now += windowMs - 1_000
expect(reserve.relay.sessions.inReserveMode()).toBe(true)
now += 2_000
expect(reserve.relay.sessions.inReserveMode()).toBe(false)
// The alert keys on this line and its cellId (relay-observability.tf).
const tripped = vi
.mocked(console.error)
.mock.calls.map(([line]) => String(line))
.filter((line) => line.includes('orca_relay_cell_reserve_dead_man_tripped'))
expect(tripped.map((line) => JSON.parse(line).cellId)).toEqual([reserve.config.cellId])
// The cell's own row says db at once, while Postgres still records reserve: the database
// path places on it by this row, whether or not any director's feed poll gets through.
await until(async () => (await reserve.admitEffectiveRow())?.mode === 'db')
expect((await reserve.admitEffectiveRow())?.cell_incarnation).toBe(reserve.relay.cellIncarnation)
await until(async () => (await reserve.controlLeases()).length === 2)
expect(seated.socket.readyState).toBe(WebSocket.OPEN)
// Latched for this switch generation: contact again does not bring reserve back.
reserve.relay.sessions.reserve({
v: 1,
directorId: 'director-a',
items: [{ userId: 'user-1', relayHostId: 'abcdefghijklmnop', epoch: 9, ttlMs: 30_000 }]
})
expect(reserve.relay.sessions.inReserveMode()).toBe(false)
// A new write of the switch does.
reserve.setAdmitMode('reserve')
expect(reserve.relay.sessions.inReserveMode()).toBe(true)
}, 30_000)
})
@@ -0,0 +1,62 @@
import { Hono } from 'hono'
import { describe, expect, it, vi } from 'vitest'
import { registerCellReserveRoutes } from './cell-reserve-routes.js'
import type { RelayConfig } from './config.js'
const cell = {
role: 'cell',
cellId: 'production-gce-c3',
rehomeAudience: 'https://relay.example/rehome',
rehomeDirectorServiceAccount: 'director@example.com'
} as RelayConfig
function app(config: RelayConfig = cell) {
const reserve = vi.fn(() => [{ outcome: 'ok' as const }])
const demote = vi.fn(() => 'demoted')
const routes = new Hono()
registerCellReserveRoutes(routes, config, {
verifyRegionalRehomeToken: async (token) => token === 'director-token',
reserve,
demote
})
const post = (path: string, body: unknown, token = 'director-token') =>
routes.request(path, {
method: 'POST',
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
body: JSON.stringify(body)
})
return { post, reserve, demote }
}
const booking = {
v: 1,
directorId: 'director-a',
items: [{ userId: 'user-1', relayHostId: 'abcdefghijklmnop', epoch: 3, ttlMs: 30_000 }]
}
describe('cell reserve routes', () => {
it('books for a director holding the rehome credential and refuses everyone else', async () => {
const { post, reserve } = app()
const ok = await post('/internal/reserve', booking)
expect(await ok.json()).toEqual({ v: 1, results: [{ outcome: 'ok' }] })
expect((await post('/internal/reserve', booking, 'someone-else')).status).toBe(401)
expect((await post('/internal/reserve', { ...booking, items: [] })).status).toBe(400)
expect((await post('/internal/reserve', { ...booking, v: 2 })).status).toBe(400)
expect(reserve).toHaveBeenCalledTimes(1)
})
it('is absent on a director and on a cell without the rehome pair', async () => {
expect((await app({ ...cell, role: 'director' }).post('/internal/reserve', booking)).status).toBe(404)
const noPair = app({ ...cell, rehomeDirectorServiceAccount: undefined })
expect((await noPair.post('/internal/demote', {})).status).toBe(404)
})
it('demotes only a fully named seat', async () => {
const { post, demote } = app()
const seat = { v: 1, userId: 'user-1', relayHostId: 'abcdefghijklmnop', epoch: 3 }
expect((await post('/internal/demote', seat)).status).toBe(400)
const reply = await post('/internal/demote', { ...seat, joinedAt: 1_000 })
expect(await reply.json()).toEqual({ v: 1, outcome: 'demoted' })
expect(demote).toHaveBeenCalledWith({ ...seat, joinedAt: 1_000 })
})
})
@@ -0,0 +1,57 @@
import type { Hono } from 'hono'
import {
CELL_DEMOTE_PATH,
CELL_RESERVE_PATH,
DemoteRequestSchema,
ReserveRequestSchema,
type DemoteRequest,
type ReserveOutcome,
type ReserveRequest
} from './cell-reserve-contract.js'
import type { RelayConfig } from './config.js'
import { readBearer } from './relay-token-verifier.js'
const MAX_BODY_BYTES = 16 * 1024
// The directors' rehome credential, as the seat feed uses. Unlike the feed these write, but a
// director can already place any host through the database, so they grant nothing new.
export function registerCellReserveRoutes(
app: Hono,
config: RelayConfig,
input: {
verifyRegionalRehomeToken: (token: string) => Promise<boolean>
reserve?: (request: ReserveRequest) => ReserveOutcome[]
demote?: (request: DemoteRequest) => string
}
): void {
const authorize = async (header: string | undefined): Promise<'ok' | 404 | 401> => {
if (config.role !== 'cell' || !input.reserve || !input.demote) return 404
if (!config.rehomeAudience || !config.rehomeDirectorServiceAccount) return 404
const bearer = readBearer(header)
return bearer && (await input.verifyRegionalRehomeToken(bearer)) ? 'ok' : 401
}
const unauthorized = (status: 404 | 401) =>
status === 404 ? { error: 'reserve_unavailable' } : { error: 'invalid_token' }
app.post(CELL_RESERVE_PATH, async (context) => {
const auth = await authorize(context.req.header('authorization'))
if (auth !== 'ok') return context.json(unauthorized(auth), auth)
if (Number(context.req.header('content-length') ?? 0) > MAX_BODY_BYTES) {
return context.json({ error: 'request_too_large' }, 413)
}
const body = ReserveRequestSchema.safeParse(await context.req.json().catch(() => null))
if (!body.success) return context.json({ error: 'invalid_request' }, 400)
return context.json({ v: 1, results: input.reserve!(body.data) })
})
app.post(CELL_DEMOTE_PATH, async (context) => {
const auth = await authorize(context.req.header('authorization'))
if (auth !== 'ok') return context.json(unauthorized(auth), auth)
if (Number(context.req.header('content-length') ?? 0) > MAX_BODY_BYTES) {
return context.json({ error: 'request_too_large' }, 413)
}
const body = DemoteRequestSchema.safeParse(await context.req.json().catch(() => null))
if (!body.success) return context.json({ error: 'invalid_request' }, 400)
return context.json({ v: 1, outcome: input.demote!(body.data) })
})
}
@@ -0,0 +1,83 @@
import type { Hono } from 'hono'
import type { CellFlags } from './cell-flags.js'
import type { CellSeatFeedPage } from './cell-seat-log.js'
import { parseCellSeatCursor } from './cell-seat-log.js'
import type { RelayConfig } from './config.js'
import type { AppliedControlFlags } from './relay-control-flag-channel.js'
import type { RelayRuntimeCounts } from './relay-observability.js'
import { readBearer } from './relay-token-verifier.js'
// Read-only, so it reuses the directors' rehome credential and grants it nothing new. Only
// cells that already hold the rehome pair serve it: giving a cell the pair turns rehome on.
export function registerCellSeatFeedRoute(
app: Hono,
config: RelayConfig,
input: {
verifyRegionalRehomeToken: (token: string) => Promise<boolean>
cellIncarnation?: string
seatFeed?: (sinceSeq: number | null) => CellSeatFeedPage
isDraining?: () => boolean
runtimeCounts?: () => RelayRuntimeCounts
cellFlags?: () => AppliedControlFlags<CellFlags>
// A placing director's poll (reserver=1) feeds the cell's dead-man.
onReserverPoll?: () => void
admitModeEffective?: () => 'db' | 'reserve'
// Switch file and dead-man only: db the moment the cell trips, while re-registration runs.
admitModeRaw?: () => 'db' | 'reserve'
// Step 5: what a director needs to estimate a seat and a booking here.
reserveCounts?: () => {
bookings: number
intake: { perSec: number; burst: number; tokens: number }
} | null
placementCeiling?: number
now?: () => number
}
): void {
app.get('/v1/admin/cell-seats', async (context) => {
if (config.role !== 'cell' || !input.seatFeed || !input.cellIncarnation) {
return context.json({ error: 'cell_only' }, 404)
}
if (!config.rehomeAudience || !config.rehomeDirectorServiceAccount) {
return context.json({ error: 'seat_feed_unavailable' }, 404)
}
const bearer = readBearer(context.req.header('authorization'))
if (!bearer || !(await input.verifyRegionalRehomeToken(bearer))) {
return context.json({ error: 'invalid_token' }, 401)
}
if (context.req.query('reserver') === '1') input.onReserverPoll?.()
const cursor = parseCellSeatCursor(context.req.query('since'))
if (cursor === 'invalid') return context.json({ error: 'invalid_request' }, 400)
const sinceSeq =
cursor !== null && cursor.incarnation === input.cellIncarnation ? cursor.seq : null
const { seats, ...page } = input.seatFeed(sinceSeq)
const counts = input.runtimeCounts?.()
const reserve = input.reserveCounts?.() ?? null
return context.json({
v: 1,
cellId: config.cellId,
incarnation: input.cellIncarnation,
at: (input.now ?? Date.now)(),
draining: input.isDraining?.() ?? false,
counts: {
controls: counts?.controls ?? 0,
// The feed's own view: a reader that applied every change holds exactly this many.
// `controls` drops when a socket starts closing, `seats` when its close lands.
seats,
...(reserve && input.placementCeiling !== undefined
? {
bookings: reserve.bookings,
// Every unit the ledger counts against the ceiling, bookings and phones included.
units: counts?.enforcedConnectionUnits ?? 0,
ceiling: input.placementCeiling
}
: {})
},
...(reserve ? { intake: reserve.intake } : {}),
// Applied, never desired: generation 0 means no object has been read since boot.
...(input.cellFlags ? { flagsApplied: input.cellFlags() } : {}),
...(input.admitModeEffective ? { admitModeEffective: input.admitModeEffective() } : {}),
...(input.admitModeRaw ? { admitModeRaw: input.admitModeRaw() } : {}),
...page
})
})
}
+407
View File
@@ -0,0 +1,407 @@
import { EventEmitter } from 'node:events'
import { RELAY_CLOSE_CODE } from '@orca-cloud/relay-contract'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type WebSocket from 'ws'
import { z } from 'zod'
import type { RelayAssignmentStore } from './assignment-store.js'
import { CELL_FLAG_DEFAULTS, type CellFlags } from './cell-flags.js'
import type { RelayConfig } from './config.js'
import type { RelayCredentialStore } from './credential-store.js'
import { HostSessionRegistry, type HostSession } from './host-session-registry.js'
import type { AppliedControlFlags } from './relay-control-flag-channel.js'
import type { RelayRuntimeObserver } from './relay-observability.js'
import type { RelayTokenClaims } from './relay-token-verifier.js'
import { ProcessQueuedByteBudget } from './splice-forwarder.js'
const adminTokenChecks = vi.hoisted(() => ({ count: 0 }))
vi.mock('./admin-token-verifier.js', () => ({
createAdminTokenVerifier: () => async (token: string) => {
adminTokenChecks.count += 1
return token === 'deploy-token'
},
createReadOnlyAdminTokenVerifier: () => async () => false,
createRegionalRehomeControlApplyTokenVerifier: () => async () => false,
createRegionalRehomeRuntimeTokenVerifier: () => async () => false,
createRegionalRehomeTokenVerifier: () => async (token: string) => token === 'rehome-token',
createRuntimeTokenVerifier: () => async () => false
}))
import { createRelayApp } from './app.js'
class FakeSocket extends EventEmitter {
readonly OPEN = 1
readonly CLOSED = 3
readyState = this.OPEN
readonly send = vi.fn()
readonly close = vi.fn((code?: number, reason?: string) => {
this.readyState = this.CLOSED
this.emit('close', code, Buffer.from(reason ?? ''))
})
}
const incarnation = '11111111-1111-4111-8111-111111111111'
// The published v1 shape, so a field rename fails here before it reaches a director.
const SeatChangeSchema = z.object({
seq: z.number(),
kind: z.enum(['join', 'leave', 'drain-only', 'active']),
userId: z.string(),
relayHostId: z.string(),
epoch: z.number(),
generation: z.number(),
state: z.enum(['active', 'drain-only']).optional(),
closeCode: z.number().optional(),
at: z.number()
})
const SeatFeedReplySchema = z.object({
v: z.literal(1),
cellId: z.string(),
incarnation: z.string(),
at: z.number(),
draining: z.boolean(),
counts: z.object({
controls: z.number(),
seats: z.number()
}),
flagsApplied: z
.object({
generation: z.number(),
flags: z.object({
readinessLocal: z.boolean(),
ticketCheck: z.enum(['off', 'shadow', 'enforce']),
rejectionFence: z.boolean(),
admitMode: z.enum(['db', 'reserve']),
reserveDryRun: z.boolean()
}),
ignoredKeys: z.array(z.string()).optional()
})
.optional(),
seq: z.number(),
changes: z.array(SeatChangeSchema).optional(),
more: z.boolean().optional(),
full: z
.array(
z.object({
userId: z.string(),
relayHostId: z.string(),
epoch: z.number(),
generation: z.number(),
state: z.enum(['active', 'drain-only']),
joinedAt: z.number()
})
)
.optional()
})
function config(overrides: Partial<RelayConfig> = {}): RelayConfig {
return {
port: 8080,
publicUrl: 'https://c7.relay.example.test',
cellUrl: 'https://c7.relay.example.test',
authIssuer: 'https://auth.example.test',
authAudience: 'orca-relay',
jwksUrl: 'https://auth.example.test/jwks',
assignmentSigningKey: new Uint8Array(32),
role: 'cell',
cellId: 'production-gce-c7',
cells: [],
adminAudience: 'https://relay.example.test/v1/admin/drain',
deployServiceAccount: 'deploy@example.test',
rehomeDirectorServiceAccount: 'relay-director@example.test',
rehomeAudience: 'https://relay.example.test/v1/admin/host-drain',
runtimeServiceAccount: 'relay-cell@example.test',
adminJwksUrl: 'https://auth.example.test/jwks',
databasePoolMax: 10,
publicAssignmentsEnabled: true,
publicAssignmentConcurrency: 2,
publicAssignmentQueueMax: 128,
publicAssignmentWaitMs: 4_000,
publicResolveConcurrency: 1,
publicResolveWaitMs: 5_000,
publicAssignmentRetryAfterSeconds: 5,
dataDir: './data',
...overrides
}
}
function hostIdentity(index: number, exp = 4_102_444_800): RelayTokenClaims {
return {
sub: `user-${index}`,
prof: 'profile-1',
relayHostId: `host${String(index).padStart(12, '0')}`,
purpose: 'host-control',
exp
}
}
// What a host's auth-refresh token verifies as.
const verifyRelayToken = vi.fn<(token: string) => Promise<RelayTokenClaims | null>>()
function createCell(relayConfig = config(), cellFlags?: () => AppliedControlFlags<CellFlags>) {
const assignments = {
activateControl: vi.fn().mockResolvedValue('control:production-gce-c7:1'),
markMigrationTargetRegistered: vi.fn().mockResolvedValue(undefined),
releaseActivity: vi.fn().mockResolvedValue(true),
renewControlActivities: vi.fn().mockResolvedValue([])
} as unknown as RelayAssignmentStore
const observer = {
recordAuth: vi.fn(),
recordForwardedBytes: vi.fn(),
recordHttp: vi.fn(),
recordReconnect: vi.fn(),
recordSql: vi.fn()
} satisfies RelayRuntimeObserver
const registry = new HostSessionRegistry(
relayConfig,
verifyRelayToken,
{} as RelayCredentialStore,
assignments,
new ProcessQueuedByteBudget(),
observer,
Date.now,
Math.random,
incarnation
)
const activate = (
registry as unknown as {
activate: (
socket: WebSocket,
identity: RelayTokenClaims,
existing: HostSession | null,
generation: number,
rebind: boolean,
assignmentEpoch: number,
appVersion: string
) => Promise<void>
}
).activate.bind(registry)
const connect = async (
index: number,
options: { generation?: number; epoch?: number; rebind?: boolean; exp?: number } = {}
): Promise<FakeSocket> => {
const socket = new FakeSocket()
const identity = hostIdentity(index, options.exp)
await activate(
socket as unknown as WebSocket,
identity,
registry.get({ userId: identity.sub, relayHostId: identity.relayHostId }),
options.generation ?? 1,
options.rebind ?? false,
options.epoch ?? 1,
'1.4.200'
)
return socket
}
const app = createRelayApp(relayConfig, {
store: {} as never,
assignments: {} as never,
drain: (graceMs, options) => registry.drain(graceMs, options ?? {}),
cellIncarnation: incarnation,
cellSeatFeed: (sinceSeq) => registry.seatFeed(sinceSeq),
cellFlags,
isDraining: () => registry.isDraining(),
runtimeCounts: () => ({
totalConnections: 0,
preAuthConnections: 0,
queuedBytes: 0,
...registry.runtimeCounts()
}),
ready: vi.fn(async () => true)
})
const poll = async (since?: string, token = 'rehome-token') =>
await app.request(
`/v1/admin/cell-seats${since === undefined ? '' : `?since=${encodeURIComponent(since)}`}`,
{ headers: { authorization: `Bearer ${token}` } }
)
const read = async (since?: string) => SeatFeedReplySchema.parse(await (await poll(since)).json())
return { registry, connect, app, poll, read }
}
describe('cell seat feed', () => {
beforeEach(() => vi.useFakeTimers())
afterEach(() => {
vi.clearAllTimers()
vi.useRealTimers()
})
it('answers a first poll with every seated host, matching the controls count', async () => {
const { connect, poll, read } = createCell()
await connect(1)
await connect(2, { epoch: 4 })
expect((await poll()).status).toBe(200)
const body = await read()
expect(body).toMatchObject({
v: 1,
cellId: 'production-gce-c7',
incarnation,
seq: 2,
draining: false,
counts: { controls: 2, seats: 2 }
})
expect(body.full).toHaveLength(body.counts.seats)
expect(body.full).toContainEqual(
expect.objectContaining({ userId: 'user-2', epoch: 4, generation: 1, state: 'active' })
)
})
it('keeps a closing socket seated until its close lands, as the change log does', async () => {
const { connect, read } = createCell()
const socket = await connect(1)
// The ws close handshake: no longer OPEN, close event not yet emitted.
socket.readyState = 2
const closing = await read()
expect(closing.counts).toMatchObject({ controls: 0, seats: 1 })
expect(closing.full).toHaveLength(1)
socket.readyState = socket.CLOSED
socket.emit('close', 1000, Buffer.from(''))
expect((await read()).counts).toMatchObject({ controls: 0, seats: 0 })
})
it('reports join, drain-only and leave as changes after the cursor', async () => {
const { registry, connect, read } = createCell()
const socket = await connect(1)
const { seq } = await read()
registry.drain(60_000)
socket.close(RELAY_CLOSE_CODE.DRAINING, 'relay draining')
await connect(2)
const body = await read(`${incarnation}:${seq}`)
expect(body.full).toBeUndefined()
expect(body.draining).toBe(true)
expect(body.changes?.map((change) => change.kind)).toEqual([
'drain-only',
'leave'
])
expect(body.changes?.[1]).toMatchObject({
userId: 'user-1',
closeCode: RELAY_CLOSE_CODE.DRAINING,
generation: 1
})
// The second connect landed after the drain, so the cell refused it: no join.
expect(body.seq).toBe(seq + 2)
})
it('reports an auth expiry as drain-only and the refresh that clears it as active', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {})
const { connect, read } = createCell()
const exp = Math.floor(Date.now() / 1000) + 5
const socket = await connect(1, { exp })
const { seq } = await read()
// The 15 s heartbeat finds the token expired, inside its grace.
await vi.advanceTimersByTimeAsync(15_000)
verifyRelayToken.mockResolvedValueOnce(hostIdentity(1, exp + 3_600))
socket.emit(
'message',
Buffer.from(JSON.stringify({ type: 'auth-refresh', relayJwt: 'refreshed' })),
false
)
await vi.waitFor(() => expect(verifyRelayToken).toHaveBeenCalled())
const body = await read(`${incarnation}:${seq}`)
expect(body.changes?.map((change) => change.kind)).toEqual(['drain-only', 'active'])
expect(body.counts.seats).toBe(1)
expect((await read()).full).toEqual([expect.objectContaining({ state: 'active' })])
})
it('does not report a rebind as a leave, and joins the rebound socket', async () => {
const { connect, read } = createCell()
await connect(1)
const { seq } = await read()
await connect(1, { rebind: true, epoch: 2 })
const body = await read(`${incarnation}:${seq}`)
expect(body.changes).toEqual([
expect.objectContaining({ kind: 'join', userId: 'user-1', epoch: 2, state: 'active' })
])
})
it('reports a superseded generation leaving after its successor joined', async () => {
const { connect, read } = createCell()
await connect(1)
const { seq } = await read()
await connect(1, { generation: 2 })
const body = await read(`${incarnation}:${seq}`)
// Readers drop the leave: its generation is older than the seat's.
expect(
body.changes?.map((change) => [change.kind, change.generation])
).toEqual([
['leave', 1],
['join', 2]
])
})
it('resyncs a cursor from another incarnation with a full snapshot', async () => {
const { connect, poll, read } = createCell()
await connect(1)
const body = await read('22222222-2222-4222-8222-222222222222:1')
expect(body.full).toHaveLength(1)
expect((await poll('not a cursor')).status).toBe(400)
})
it('reports the applied cell flags, so a flip can be read back through the feed and runtime status', async () => {
let applied: AppliedControlFlags<CellFlags> = { generation: 0, flags: CELL_FLAG_DEFAULTS }
const { read, app } = createCell(config(), () => applied)
expect((await read()).flagsApplied).toEqual({ generation: 0, flags: CELL_FLAG_DEFAULTS })
applied = { generation: 12, flags: { ...CELL_FLAG_DEFAULTS, readinessLocal: true, ticketCheck: 'shadow' } }
expect((await read()).flagsApplied).toEqual(applied)
const runtime = await app.request('/v1/admin/runtime-status', {
method: 'POST',
headers: { authorization: 'Bearer deploy-token', 'content-type': 'application/json' },
body: JSON.stringify({ v: 1 })
})
expect(await runtime.json()).toMatchObject({
flagsApplied: applied,
databasePoolMax: expect.any(Number),
// What the flag tool checks a write against before it writes.
supportedFlags: {
readinessLocal: { type: 'boolean' },
ticketCheck: { type: 'enum', values: ['off', 'shadow'] },
rejectionFence: { type: 'boolean' },
readTimeoutMarginMs: { type: 'number', min: 1_000, max: 60_000, integer: true },
admitMode: { type: 'enum', values: ['db', 'reserve'] },
intakePerSec: { type: 'number', max: 1_000 },
reserveDryRun: { type: 'boolean' },
reregisterInFlight: { type: 'number', min: 1, max: 16, integer: true }
}
})
})
it('accepts only the directors rehome identity, verified once per poll', async () => {
const { poll } = createCell()
adminTokenChecks.count = 0
expect((await poll(undefined, 'deploy-token')).status).toBe(401)
expect((await poll(undefined, 'wrong')).status).toBe(401)
expect((await poll()).status).toBe(200)
// The admin middleware leaves this route to its own rehome check.
expect(adminTokenChecks.count).toBe(0)
})
it('is unavailable on a cell without the rehome pair, and on directors', async () => {
const withoutPair = createCell(
config({ rehomeAudience: undefined, rehomeDirectorServiceAccount: undefined })
)
const response = await withoutPair.poll()
expect(response.status).toBe(404)
expect(await response.json()).toEqual({ error: 'seat_feed_unavailable' })
const director = createCell(config({ role: 'director', cellId: 'director' }))
expect((await director.poll()).status).toBe(404)
})
// Review B1: the feed must not need the pair on more cells, and must not change what a cell
// with or without it reports as its rehome protocol.
it('leaves the reported rehome protocol to the existing env', async () => {
const rehomeProtocol = async (relayConfig: RelayConfig) => {
const response = await createCell(relayConfig).app.request('/v1/admin/runtime-status', {
method: 'POST',
headers: { authorization: 'Bearer deploy-token', 'content-type': 'application/json' },
body: JSON.stringify({ v: 1 })
})
return z.object({ regionalRehomeProtocol: z.number() }).parse(await response.json())
.regionalRehomeProtocol
}
expect(await rehomeProtocol(config())).toBe(3)
expect(
await rehomeProtocol(
config({ rehomeAudience: undefined, rehomeDirectorServiceAccount: undefined })
)
).toBe(0)
})
})
+141
View File
@@ -0,0 +1,141 @@
import { describe, expect, it } from 'vitest'
import { CELL_SEAT_FEED_PAGE_MAX, CellSeatLog, parseCellSeatCursor } from './cell-seat-log.js'
function append(log: CellSeatLog, count: number): void {
for (let index = 0; index < count; index += 1) {
log.append({
kind: 'join',
userId: `user-${index}`,
relayHostId: 'abcdefghijklmnop',
epoch: 1,
generation: 1,
at: index
})
}
}
describe('cell seat log', () => {
it('answers a missing cursor with the live seats and the head as the next cursor', () => {
const log = new CellSeatLog(8)
append(log, 3)
const page = log.read(null)
expect(page.seq).toBe(3)
expect('full' in page && page.full.map((seat) => seat.userId)).toEqual([
'user-0',
'user-1',
'user-2'
])
})
it('returns only the changes after the cursor', () => {
const log = new CellSeatLog(8)
append(log, 5)
const page = log.read(3)
expect(page).toMatchObject({ seq: 5, more: false })
expect('changes' in page && page.changes.map((change) => change.seq)).toEqual([4, 5])
expect(log.read(5)).toEqual({ seq: 5, changes: [], more: false })
})
it('resyncs with a full snapshot once the cursor has fallen out of the ring', () => {
const log = new CellSeatLog(4)
append(log, 10)
// Seqs 7..10 remain, so a cursor at 6 still continues and 5 does not.
expect(log.read(6)).toMatchObject({ seq: 10, more: false })
expect(log.read(5)).toMatchObject({ seq: 10, full: expect.any(Array) })
})
it('resyncs a cursor ahead of the head instead of skipping changes', () => {
const log = new CellSeatLog(4)
append(log, 2)
expect(log.read(9)).toMatchObject({ seq: 2, full: expect.any(Array) })
})
it('pages a long backlog and resumes from the returned cursor', () => {
const log = new CellSeatLog(CELL_SEAT_FEED_PAGE_MAX * 2)
append(log, CELL_SEAT_FEED_PAGE_MAX + 5)
const first = log.read(0)
expect(first).toMatchObject({ seq: CELL_SEAT_FEED_PAGE_MAX, more: true })
expect('changes' in first && first.changes).toHaveLength(CELL_SEAT_FEED_PAGE_MAX)
const second = log.read(first.seq)
expect(second).toMatchObject({ seq: CELL_SEAT_FEED_PAGE_MAX + 5, more: false })
expect('changes' in second && second.changes).toHaveLength(5)
})
})
describe('cell seat log snapshot', () => {
const change = (kind: 'join' | 'leave' | 'drain-only', generation: number, state?: 'drain-only') => ({
kind,
userId: 'user-1',
relayHostId: 'abcdefghijklmnop',
epoch: generation,
generation,
...(state ? { state } : {}),
at: generation
})
it('holds exactly what a reader applying every change by generation holds', () => {
const log = new CellSeatLog(8)
log.append(change('join', 1))
log.append(change('join', 2))
// The superseded generation's close lands after its successor joined.
log.append(change('leave', 1))
expect(log.seatCount()).toBe(1)
log.append(change('drain-only', 2))
expect(log.read(null)).toMatchObject({
full: [{ userId: 'user-1', generation: 2, epoch: 2, state: 'drain-only', joinedAt: 2 }]
})
log.append(change('leave', 2))
expect(log.seatCount()).toBe(0)
// A drain notice for a host with no seat seats nothing.
log.append(change('drain-only', 3))
expect(log.read(null)).toMatchObject({ full: [] })
})
})
describe('cell seat cursor', () => {
it('reads an incarnation and a sequence, and refuses anything else', () => {
expect(parseCellSeatCursor(undefined)).toBeNull()
expect(parseCellSeatCursor('')).toBeNull()
expect(parseCellSeatCursor('11111111-1111-4111-8111-111111111111:42')).toEqual({
incarnation: '11111111-1111-4111-8111-111111111111',
seq: 42
})
expect(parseCellSeatCursor('incarnation')).toBe('invalid')
expect(parseCellSeatCursor('a:-1')).toBe('invalid')
expect(parseCellSeatCursor('a b:1')).toBe('invalid')
})
it('never seats a booking, and remembers leavers for ten minutes for the full snapshot', () => {
let now = 0
const log = new CellSeatLog(64, () => now)
const host = { userId: 'user-1', relayHostId: 'abcdefghijklmnop' }
log.append({ kind: 'reserve', ...host, epoch: 4, generation: 0, reservedBy: 'd1', at: now })
expect(log.seatCount()).toBe(0)
log.append({ kind: 'join', ...host, epoch: 4, generation: 1, reservedBy: 'd1', at: now })
expect(log.seatOf(host.userId, host.relayHostId)).toMatchObject({ epoch: 4, state: 'active' })
now = 1_000
log.append({ kind: 'leave', ...host, epoch: 4, generation: 1, closeCode: 1006, at: now })
expect(log.recentlyLeftOf(host.userId, host.relayHostId)).toEqual({ ...host, epoch: 4, closeCode: 1006, at: 1_000 })
const page = log.read(null)
expect('full' in page && page.recentlyLeft).toEqual([{ ...host, epoch: 4, closeCode: 1006, at: 1_000 }])
now = 1_000 + 10 * 60_000
expect(log.recentlyLeftOf(host.userId, host.relayHostId)).toBeUndefined()
})
it('sends only the newest page of leavers in a full snapshot', () => {
const log = new CellSeatLog(CELL_SEAT_FEED_PAGE_MAX * 4, () => 0)
const leavers = CELL_SEAT_FEED_PAGE_MAX + 5
for (let index = 0; index < leavers; index += 1) {
const host = { userId: `user-${index}`, relayHostId: 'abcdefghijklmnop', epoch: 1, generation: 1 }
log.append({ kind: 'join', ...host, at: 0 })
log.append({ kind: 'leave', ...host, closeCode: 1006, at: 0 })
}
const page = log.read(null)
const left = 'full' in page ? page.recentlyLeft : []
expect(left).toHaveLength(CELL_SEAT_FEED_PAGE_MAX)
expect(left.at(-1)?.userId).toBe(`user-${leavers - 1}`)
// The cell itself still remembers the oldest.
expect(log.recentlyLeftOf('user-0', 'abcdefghijklmnop')).toBeDefined()
})
})
+170
View File
@@ -0,0 +1,170 @@
// Seat changes on this cell, read by directors through `GET /v1/admin/cell-seats`.
// Wire contract (v1); readers must ignore unknown fields:
// since=<incarnation>:<seq> returns `changes` after seq, or `full` when the cursor is
// absent, from another incarnation, or older than the ring. `seq` in the reply is the
// cursor for the next poll; `more` says a page was cut at CELL_SEAT_FEED_PAGE_MAX.
// Apply a change only when its generation is >= the seat's: a superseded generation's
// socket can close after its successor joined. `full` is this log's own seats under the
// same rules, so a reader that applies every change holds exactly `counts.seats`.
// Step 5 adds `reserve` (a booking, never a seat) and `reservedBy` on the join it admitted;
// a full snapshot also carries `recentlyLeft`, this cell's leavers of the last 10 minutes.
export type CellSeatState = 'active' | 'drain-only'
export type CellSeatChange = {
seq: number
// `active` lifts a drain-only the host cleared itself (a refreshed token after it expired).
kind: 'join' | 'leave' | 'drain-only' | 'active' | 'reserve'
userId: string
relayHostId: string
epoch: number
generation: number
// Only on join: a rebind under a regional drain joins already drain-only.
state?: CellSeatState
// Only on leave.
closeCode?: number
// On reserve, and on the join a booking admitted: the director that booked it.
reservedBy?: string
at: number
}
export type CellSeat = {
userId: string
relayHostId: string
epoch: number
generation: number
state: CellSeatState
joinedAt: number
}
export type RecentlyLeftCellSeat = {
userId: string
relayHostId: string
epoch: number
closeCode?: number
at: number
}
export type CellSeatPage =
| { seq: number; changes: CellSeatChange[]; more: boolean }
| { seq: number; full: CellSeat[]; recentlyLeft: RecentlyLeftCellSeat[] }
// What the registry hands the route: a page plus the log's own seat count.
export type CellSeatFeedPage = CellSeatPage & { seats: number }
// ~10 minutes of a full-cell drain's joins and leaves (inferred), at ~130 B each.
export const CELL_SEAT_LOG_CAPACITY = 20_000
export const CELL_SEAT_FEED_PAGE_MAX = 2_000
// How long a leaver may rejoin here at the same epoch without the database (step 5 rule 2).
export const CELL_RECENT_SEAT_MS = 10 * 60_000
const CELL_RECENT_SEAT_MAX = 20_000
export class CellSeatLog {
private readonly ring: (CellSeatChange | undefined)[]
private readonly seats = new Map<string, CellSeat>()
// Insertion order is age order.
private readonly recent = new Map<string, RecentlyLeftCellSeat>()
private headSeq = 0
constructor(
private readonly capacity = CELL_SEAT_LOG_CAPACITY,
private readonly now: () => number = Date.now
) {
this.ring = new Array<CellSeatChange | undefined>(capacity)
}
append(change: Omit<CellSeatChange, 'seq'>): void {
this.headSeq += 1
this.ring[this.headSeq % this.capacity] = { seq: this.headSeq, ...change }
this.applyToSeats(change)
}
seatCount(): number {
return this.seats.size
}
// `sinceSeq` null means the caller has no cursor for this incarnation.
read(sinceSeq: number | null): CellSeatPage {
const oldestSeq = Math.max(1, this.headSeq - this.capacity + 1)
if (sinceSeq === null || sinceSeq > this.headSeq || sinceSeq < oldestSeq - 1) {
return {
seq: this.headSeq,
full: [...this.seats.values()].map((seat) => ({ ...seat })),
recentlyLeft: this.recentlyLeft()
}
}
const lastSeq = Math.min(this.headSeq, sinceSeq + CELL_SEAT_FEED_PAGE_MAX)
const changes: CellSeatChange[] = []
for (let seq = sinceSeq + 1; seq <= lastSeq; seq += 1) {
changes.push(this.ring[seq % this.capacity]!)
}
return { seq: lastSeq, changes, more: lastSeq < this.headSeq }
}
seatOf(userId: string, relayHostId: string): CellSeat | undefined {
return this.seats.get(JSON.stringify([userId, relayHostId]))
}
recentlyLeftOf(userId: string, relayHostId: string): RecentlyLeftCellSeat | undefined {
const entry = this.recent.get(JSON.stringify([userId, relayHostId]))
return entry && entry.at > this.now() - CELL_RECENT_SEAT_MS ? entry : undefined
}
// The newest page's worth: an older leaver the director misses only takes the database path.
private recentlyLeft(): RecentlyLeftCellSeat[] {
const cutoff = this.now() - CELL_RECENT_SEAT_MS
const live = [...this.recent.values()].filter((entry) => entry.at > cutoff)
return live.slice(-CELL_SEAT_FEED_PAGE_MAX).map((entry) => ({ ...entry }))
}
private applyToSeats(change: Omit<CellSeatChange, 'seq'>): void {
// A booking is not a seat.
if (change.kind === 'reserve') return
const key = JSON.stringify([change.userId, change.relayHostId])
const seat = this.seats.get(key)
if (seat && change.generation < seat.generation) return
if (change.kind === 'join') {
this.recent.delete(key)
this.seats.set(key, {
userId: change.userId,
relayHostId: change.relayHostId,
epoch: change.epoch,
generation: change.generation,
state: change.state ?? 'active',
joinedAt: change.at
})
} else if (change.kind === 'leave') {
if (!seat) return
this.seats.delete(key)
this.rememberLeft(key, {
userId: change.userId,
relayHostId: change.relayHostId,
epoch: seat.epoch,
...(change.closeCode === undefined ? {} : { closeCode: change.closeCode }),
at: change.at
})
} else if (seat) {
seat.state = change.kind === 'active' ? 'active' : 'drain-only'
}
}
private rememberLeft(key: string, entry: RecentlyLeftCellSeat): void {
this.recent.delete(key)
this.recent.set(key, entry)
const cutoff = entry.at - CELL_RECENT_SEAT_MS
for (const [oldestKey, oldest] of this.recent) {
if (this.recent.size <= CELL_RECENT_SEAT_MAX && oldest.at > cutoff) break
this.recent.delete(oldestKey)
}
}
}
// Malformed cursors are a caller bug, so they are refused rather than resynced.
export function parseCellSeatCursor(
value: string | undefined
): { incarnation: string; seq: number } | null | 'invalid' {
if (value === undefined || value === '') return null
const match = /^([A-Za-z0-9-]{1,64}):(\d{1,15})$/.exec(value)
if (!match) return 'invalid'
return { incarnation: match[1]!, seq: Number(match[2]) }
}
+19
View File
@@ -99,6 +99,21 @@ const EnvSchema = z.object({
ORCA_RELAY_PUBLIC_ASSIGNMENTS_ENABLED: EnvironmentBooleanSchema,
ORCA_RELAY_REGIONAL_PLACEMENT_ENABLED: EnvironmentBooleanSchema,
ORCA_RELAY_REGION_CORRECTION_COHORT_PERCENT: z.coerce.number().int().min(0).max(100).default(0),
// Step 5 on directors: `off` (default) is today's path; `dry-run` asks the picked cell to
// check a booking without making it, and logs the answer; `on` places on switched-on cells.
ORCA_RELAY_RESERVE_PLACEMENT: z.enum(['off', 'dry-run', 'on']).default('off'),
// Step 3 shadow directory: unset or empty polls no cell; `all` or a comma list of cell ids.
ORCA_RELAY_SHADOW_SEAT_FEED_CELLS: z
.string()
.default('')
.transform((value) =>
value.trim() === 'all'
? ('all' as const)
: value
.split(',')
.map((cellId) => cellId.trim())
.filter((cellId) => cellId.length > 0)
),
ORCA_RELAY_PUBLIC_ASSIGNMENT_CONCURRENCY: z.coerce.number().int().positive().max(100).default(2),
ORCA_RELAY_PUBLIC_STICKY_CONCURRENCY: z.coerce.number().int().positive().max(100).default(1),
ORCA_RELAY_PUBLIC_STICKY_QUEUE_MAX: z.coerce.number().int().positive().max(4_096).default(64),
@@ -205,6 +220,8 @@ export type RelayConfig = {
fenceBrokerServiceAccount?: string
rehomeDirectorServiceAccount?: string
rehomeAudience?: string
shadowSeatFeedCells?: 'all' | string[]
reservePlacement?: 'off' | 'dry-run' | 'on'
runtimeServiceAccount: string
directorUrl?: string
heartbeatAudience?: string
@@ -354,6 +371,8 @@ export function loadRelayConfig(env: NodeJS.ProcessEnv = process.env): RelayConf
fenceBrokerServiceAccount: parsed.ORCA_RELAY_FENCE_BROKER_SERVICE_ACCOUNT,
rehomeDirectorServiceAccount: parsed.ORCA_RELAY_REHOME_DIRECTOR_SERVICE_ACCOUNT,
rehomeAudience: parsed.ORCA_RELAY_REHOME_AUDIENCE,
shadowSeatFeedCells: parsed.ORCA_RELAY_SHADOW_SEAT_FEED_CELLS,
reservePlacement: parsed.ORCA_RELAY_RESERVE_PLACEMENT,
runtimeServiceAccount:
parsed.ORCA_RELAY_RUNTIME_SERVICE_ACCOUNT ?? parsed.ORCA_RELAY_DEPLOY_SERVICE_ACCOUNT,
directorUrl,
@@ -46,6 +46,7 @@ vi.mock('pg', () => ({
import {
openRelayDatabase,
POSTGRES_SCHEMA_MIGRATIONS,
relayPostgresReadTimeoutMarginMs,
relayPostgresStatementTimeoutMs
} from './database.js'
import { applyPostgresSchema } from './postgres-schema-startup.js'
@@ -89,10 +90,15 @@ describe('PostgreSQL relay deadlines', () => {
application_name: 'orca-relay/director/director',
connectionTimeoutMillis: 2_000,
statement_timeout: 5_000,
// A lost reply ends at the statement deadline plus 10 s, on this pool only.
query_timeout: 15_000,
lock_timeout: 1_000,
idle_in_transaction_session_timeout: 5_000
idle_in_transaction_session_timeout: 5_000,
options: '-c idle_session_timeout=30000'
})
])
expect(fakes.configs[0]).not.toHaveProperty('query_timeout')
expect(fakes.configs[0]).not.toHaveProperty('options')
await database.close()
})
@@ -160,7 +166,7 @@ describe('PostgreSQL relay deadlines', () => {
expect(fakes.configs).toEqual([
expect.objectContaining({ statement_timeout: 0 }),
expect.objectContaining({ statement_timeout: 2_500 })
expect.objectContaining({ statement_timeout: 2_500, query_timeout: 12_500 })
])
await database.close()
})
@@ -174,6 +180,18 @@ describe('PostgreSQL relay deadlines', () => {
}
)
it('takes the read timeout margin from the environment, defaulting to 10 s', () => {
expect(relayPostgresReadTimeoutMarginMs({})).toBe(10_000)
expect(
relayPostgresReadTimeoutMarginMs({ ORCA_RELAY_POSTGRES_READ_TIMEOUT_MARGIN_MS: '4000' })
).toBe(4_000)
for (const value of ['0', '-1', '2.5', 'soon']) {
expect(() =>
relayPostgresReadTimeoutMarginMs({ ORCA_RELAY_POSTGRES_READ_TIMEOUT_MARGIN_MS: value })
).toThrow('invalid_read_timeout_margin')
}
})
it.each([undefined, ''])('defaults to 5s when the environment says %s', (value) => {
expect(
relayPostgresStatementTimeoutMs(
@@ -1,23 +1,56 @@
import { describe, expect, it, vi } from 'vitest'
import { isRelayDatabaseTransientError } from './database.js'
import { isRelayDatabaseTransientError, PostgresDatabase } from './database.js'
import { PostgresPoolPressure } from './postgres-pool-pressure.js'
// The only way to mark an error as an acquire failure is to fail a real
// acquire, so the gated cases go through the pressure wrapper the pool uses.
async function failedAcquire(message: string): Promise<unknown> {
async function failedAcquire(failure: string | Error): Promise<unknown> {
const pool = {
totalCount: 0,
idleCount: 0,
waitingCount: 0,
connect: vi.fn(async () => {
throw new Error(message)
throw typeof failure === 'string' ? new Error(failure) : failure
})
}
return await new PostgresPoolPressure(pool as never).connect().catch((error: unknown) => error)
}
async function failedQuery(failure: Error): Promise<unknown> {
const client = {
query: vi.fn(async () => {
throw failure
}),
release: vi.fn(),
on: vi.fn(),
removeListener: vi.fn()
}
const database = new PostgresDatabase({
totalCount: 1,
idleCount: 0,
waitingCount: 0,
connect: async () => client
} as never)
vi.spyOn(console, 'warn').mockImplementation(() => {})
return await database.query('SELECT 1').catch((error: unknown) => error)
}
describe('relay database transient errors', () => {
it.each(['40P01', '40001', '55P03', '57014', '53300', '57P03', '08001', '08006'])(
it.each([
'40P01',
'40001',
'55P03',
'57014',
'53300',
'57P01',
'57P02',
'57P03',
'08000',
'08001',
'08003',
'08006',
'25P03'
])(
'classifies PostgreSQL code %s as retryable overload',
(code) => {
expect(isRelayDatabaseTransientError({ code })).toBe(true)
@@ -40,15 +73,50 @@ describe('relay database transient errors', () => {
).toBe(true)
})
it('classifies an early-ended socket only when it ended during the acquire', async () => {
it('classifies an early-ended socket during the acquire', async () => {
expect(
isRelayDatabaseTransientError(await failedAcquire('Connection terminated unexpectedly'))
).toBe(true)
// The same message mid-statement leaves the commit outcome unknown, so it
// must stay a hard failure rather than invite a retry.
expect(
isRelayDatabaseTransientError(new Error('Connection terminated unexpectedly'))
).toBe(false)
})
// Staging B1 re-test: a Cloud SQL restart failed the transaction's acquire with `write EPIPE`.
it.each(['EPIPE', 'ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'EHOSTUNREACH'])(
'classifies an acquire that failed with %s, but not the bare errno',
async (code) => {
const failure = (): Error => Object.assign(new Error(`connect ${code}`), { code })
expect(isRelayDatabaseTransientError(await failedAcquire(failure()))).toBe(true)
expect(isRelayDatabaseTransientError(failure())).toBe(false)
}
)
it('classifies an EPIPE from the transaction acquire', async () => {
const database = new PostgresDatabase({
totalCount: 0,
idleCount: 0,
waitingCount: 0,
connect: async () => {
throw Object.assign(new Error('write EPIPE'), { code: 'EPIPE' })
}
} as never)
const error = await database.transaction(async () => 'unreached').catch((e: unknown) => e)
expect(error).toMatchObject({ code: 'EPIPE' })
expect(isRelayDatabaseTransientError(error)).toBe(true)
})
// Staging B1: pg_terminate_backend mid-request answered /v1/assign with 500. A dropped
// connection is 503 + Retry-After, as 08006 always was; transaction() still never retries it,
// since the commit outcome is unknown.
it('classifies a mid-statement drop only from the database layer', async () => {
const failures = [
(): Error => new Error('Connection terminated unexpectedly'),
(): Error => Object.assign(new Error('read ECONNRESET'), { code: 'ECONNRESET' }),
(): Error => Object.assign(new Error('write EPIPE'), { code: 'EPIPE' })
]
for (const failure of failures) {
expect(isRelayDatabaseTransientError(await failedQuery(failure()))).toBe(true)
// The same errno or message from any other socket is not a database answer.
expect(isRelayDatabaseTransientError(failure())).toBe(false)
}
})
it.each([null, undefined, 'a thrown string'])(
+2
View File
@@ -62,6 +62,8 @@ describe('relay database', () => {
'relay_assignments',
'relay_audit_events',
'relay_cell_admission',
'relay_cell_admit_effective',
'relay_cell_admit_modes',
'relay_cell_capabilities',
'relay_cell_committed_fences',
'relay_cell_connection_limits',
+210 -17
View File
@@ -14,6 +14,11 @@ import {
import { applyPostgresSchema } from './postgres-schema-startup.js'
import { POSTGRES_STATEMENT_STATS_MIGRATION } from './postgres-statement-stats.js'
import { reportPostgresQueryFailure } from './postgres-query-failure.js'
import {
isPostgresReadTimeout,
isRelayDatabaseLayerError,
markRelayDatabaseError
} from './relay-database-rejection-fence.js'
import {
CellInventoryHoldSamples,
emptyCellInventoryHoldCounts,
@@ -354,6 +359,22 @@ CREATE TABLE IF NOT EXISTS relay_cell_admission (
roll_isolated_at BIGINT
);
-- Step 5: written by the flag workflow. A cell with no row is in database mode.
CREATE TABLE IF NOT EXISTS relay_cell_admit_modes (
cell_id TEXT PRIMARY KEY,
admit_mode TEXT NOT NULL CHECK (admit_mode IN ('db', 'reserve')),
updated_at BIGINT NOT NULL
);
-- Step 5: written by each cell, its raw mode (switch file and dead-man latch). The database
-- path places on a reserve cell only while this row, from its current process, says db.
CREATE TABLE IF NOT EXISTS relay_cell_admit_effective (
cell_id TEXT PRIMARY KEY,
cell_incarnation TEXT NOT NULL,
mode TEXT NOT NULL CHECK (mode IN ('db', 'reserve')),
updated_at BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS relay_admission_selectors (
selector_id TEXT PRIMARY KEY,
generation BIGINT NOT NULL,
@@ -706,7 +727,14 @@ export const POSTGRES_SCHEMA_MIGRATIONS = [
// Deferrable for the same reason, though SHARE UPDATE EXCLUSIVE blocks only vacuum and DDL: it
// buys nothing until the drop lands, so a boot that deferred the drop should defer this too.
`-- schema-deferrable: buys nothing until the drop above lands
ALTER TABLE relay_assignment_activity_leases SET (fillfactor = 70)`
ALTER TABLE relay_assignment_activity_leases SET (fillfactor = 70)`,
// Why: each reservations autovacuum read its ~5.5 GB of indexes in ~3 min, about hourly, evicting
// the cache of the Cloud SQL instance shared with auth. 20 ms per 200 cost units spreads a run
// over ~15 min. Deferrable: a running vacuum holds the same lock, and nothing at boot needs this.
`-- schema-deferrable: a running vacuum holds the lock this needs
ALTER TABLE relay_control_connection_reservations SET (autovacuum_vacuum_cost_delay = 20)`,
`-- schema-deferrable: a running vacuum holds the lock this needs
ALTER TABLE relay_control_connection_reservations SET (autovacuum_vacuum_cost_limit = 200)`
]
// The exact statement list a Postgres boot applies, in order, so the lock-target census can read
@@ -876,14 +904,55 @@ function inlinePostgresParameters(sql: string, params: unknown[], client: pg.Poo
return inlined
}
// A per-query read timeout, when the cell's switch overrides the pool's (pg reads
// `query_timeout` from a query config object first). Undefined keeps the pool's.
type PostgresQueryTimeout = () => number | undefined
async function timedQuery(
client: pg.PoolClient,
text: string,
values: unknown[] | undefined,
queryTimeout: PostgresQueryTimeout | undefined
): Promise<pg.QueryResult> {
const timeout = queryTimeout?.()
if (timeout === undefined) return values === undefined ? await client.query(text) : await client.query(text, values)
// pg@8.22 reads it at client.js:660; @types/pg does not declare it on QueryConfig.
const config: pg.QueryConfig & { query_timeout: number } = {
text,
...(values === undefined ? {} : { values }),
query_timeout: timeout
}
return await client.query(config)
}
class PostgresTransaction implements RelayDatabase {
readonly dialect = 'postgres' as const
private held: { fromMs: number; site: CellLockHoldSite } | undefined
private lockUnavailable = 0
private lockTimeouts = 0
private state: 'open' | 'committed' | 'rolled-back' = 'open'
// A read timeout leaves the statement running with its reply lost: pg keeps it as the
// client's active query, so anything sent next queues behind it, and if it then lands, a
// later COMMIT would commit a statement its caller saw fail. The connection is finished.
private lostReply: Error | undefined
constructor(protected readonly client: pg.PoolClient) {}
constructor(
protected readonly client: pg.PoolClient,
private readonly queryTimeout?: PostgresQueryTimeout
) {}
get poisonedBy(): Error | undefined {
return this.lostReply
}
private failIfPoisoned(): void {
if (this.lostReply) throw this.lostReply
}
private noteFailure(error: unknown): void {
markRelayDatabaseError(error)
if (isPostgresReadTimeout(error)) this.lostReply ??= error
}
get open(): boolean {
return this.state === 'open'
@@ -891,21 +960,26 @@ class PostgresTransaction implements RelayDatabase {
async commitWithFinal(sql: string, params: unknown[] = []): Promise<boolean> {
this.assertNotCommitted()
this.failIfPoisoned()
// Zero rows divides by zero, so the message stops before COMMIT exactly when the write missed.
const message =
`WITH final_write AS (${inlinePostgresParameters(sql, params, this.client)}) ` +
'SELECT 1 / (SELECT count(*)::int FROM final_write); COMMIT'
try {
await this.client.query(message)
await timedQuery(this.client, message, undefined, this.queryTimeout)
} catch (error) {
// Simple query stops at the first error, so any server error means COMMIT never ran:
// retryable codes take the caller's normal rollback-and-retry path. A lost connection
// leaves the outcome unknown, and nothing retries it.
this.noteFailure(error)
if (String((error as { code?: unknown }).code) !== '22012') {
rememberPostgresTransactionPhase(error, sql)
throw error
}
await this.client.query('ROLLBACK')
await this.client.query('ROLLBACK').catch((rollbackError: unknown) => {
this.noteFailure(rollbackError)
throw rollbackError
})
this.state = 'rolled-back'
return false
}
@@ -941,10 +1015,12 @@ class PostgresTransaction implements RelayDatabase {
async query(sql: string, params: unknown[] = []): Promise<SqlRow[]> {
this.assertNotCommitted()
this.failIfPoisoned()
try {
const result = await this.client.query(postgresSql(sql), params)
const result = await timedQuery(this.client, postgresSql(sql), params, this.queryTimeout)
return returnsRows(sql) ? (result.rows as SqlRow[]) : [{ changes: result.rowCount ?? 0 }]
} catch (error) {
this.noteFailure(error)
rememberPostgresTransactionPhase(error, sql)
throw error
}
@@ -1008,6 +1084,11 @@ const POSTGRES_CONNECTION_TIMEOUT_MS = 2_000
// leaves room for the connect timeout above.
export const POSTGRES_STATEMENT_TIMEOUT_MS = 5_000
const POSTGRES_IDLE_TRANSACTION_TIMEOUT_MS = 5_000
// Past statement_timeout because a whole-VM database stall delays replies the server has
// already timed: 5-7 s stalls are routine (connect-burst RCA), and a read timeout inside one
// would destroy healthy clients and fail requests that succeed today.
export const POSTGRES_READ_TIMEOUT_MARGIN_MS = 10_000
const POSTGRES_IDLE_SESSION_TIMEOUT_MS = 30_000
export function relayPostgresStatementTimeoutMs(
env: NodeJS.ProcessEnv = process.env
@@ -1023,6 +1104,18 @@ export function relayPostgresStatementTimeoutMs(
return milliseconds
}
export function relayPostgresReadTimeoutMarginMs(
env: NodeJS.ProcessEnv = process.env
): number {
const configured = env.ORCA_RELAY_POSTGRES_READ_TIMEOUT_MARGIN_MS
if (configured === undefined || configured === '') return POSTGRES_READ_TIMEOUT_MARGIN_MS
const milliseconds = Number(configured)
if (!Number.isInteger(milliseconds) || milliseconds < 1) {
throw new Error('invalid_read_timeout_margin')
}
return milliseconds
}
function retryablePostgresTransactionError(error: unknown): boolean {
const code = String((error as { code?: unknown }).code)
// 57014 is the pool statement_timeout firing. It aborts the transaction the
@@ -1035,9 +1128,41 @@ export function isRelayDatabaseTransientError(error: unknown): boolean {
// Runs inside the query catch, where a thrown null or undefined would turn a
// database failure into a TypeError that buries it.
const code = String((error as { code?: unknown } | null)?.code)
if (['40P01', '40001', '55P03', '57014', '53300', '57P03', '08001', '08006'].includes(code)) {
if (
[
'40P01',
'40001',
'55P03',
'57014',
'53300',
'57P03',
'08000',
'08001',
'08003',
'08006',
// The server ended the session: pg_terminate_backend or a fast/immediate shutdown.
'57P01',
'57P02',
// The server ended a session idle in a transaction past its limit (a database stall).
'25P03'
].includes(code)
) {
return true
}
// The connection dropped mid-statement: pg's own message, or the socket's reset. Only from
// the database layer, since any other socket can raise the same errno.
if (
isRelayDatabaseLayerError(error) &&
(code === 'ECONNRESET' ||
code === 'EPIPE' ||
String((error as { message?: unknown }).message).startsWith('Connection terminated'))
) {
return true
}
// A lost reply, answered 503 like 08006. Its write may have landed, so transaction() never
// retries it. The one startup retry that can see it, the director's cell reconcile
// (cell-admission-startup.ts), re-runs only upserts that converge on the same rows.
if (isPostgresReadTimeout(error)) return true
// A pool that cannot hand out a client reports no SQLSTATE at all, so the
// acquire boundary owns that vocabulary.
return isPostgresPoolConnectFailure(error)
@@ -1057,7 +1182,10 @@ export class PostgresDatabase implements RelayDatabase {
return this.holds.consumeCounts()
}
constructor(private readonly pool: pg.Pool) {
constructor(
private readonly pool: pg.Pool,
private readonly queryTimeout?: PostgresQueryTimeout
) {
this.pressure = new PostgresPoolPressure(pool)
}
@@ -1077,12 +1205,15 @@ export class PostgresDatabase implements RelayDatabase {
const startedAt = performance.now()
let phase: 'acquire' | 'execute' = 'acquire'
let client: pg.PoolClient | undefined
let lostReply: Error | undefined
try {
client = await this.pressure.connect(lane)
phase = 'execute'
const result = await client.query(postgresSql(sql), params)
const result = await timedQuery(client, postgresSql(sql), params, this.queryTimeout)
return returnsRows(sql) ? (result.rows as SqlRow[]) : [{ changes: result.rowCount ?? 0 }]
} catch (error) {
markRelayDatabaseError(error)
if (isPostgresReadTimeout(error)) lostReply = error
reportPostgresQueryFailure({
error,
phase,
@@ -1095,7 +1226,8 @@ export class PostgresDatabase implements RelayDatabase {
})
throw error
} finally {
client?.release()
// An error destroys the client rather than handing the next caller a dead connection.
client?.release(lostReply)
}
}
@@ -1126,23 +1258,31 @@ export class PostgresDatabase implements RelayDatabase {
): Promise<T> {
for (let attempt = 1; attempt <= POSTGRES_TRANSACTION_ATTEMPTS; attempt++) {
const client = await this.pressure.connect()
const transaction = new PostgresTransaction(client)
const transaction = new PostgresTransaction(client, this.queryTimeout)
let lostReply: Error | undefined
try {
await client.query('BEGIN')
await timedQuery(client, 'BEGIN', undefined, this.queryTimeout)
const result = await operation(transaction)
if (transaction.open) await client.query('COMMIT')
// Even when the operation caught it: COMMIT must not run after a lost reply.
if (transaction.poisonedBy) throw transaction.poisonedBy
if (transaction.open) await timedQuery(client, 'COMMIT', undefined, this.queryTimeout)
recordMeasuredHold(this.holds, transaction)
this.holds.recordUnavailable(transaction.consumeLockUnavailable())
this.holds.recordLockTimeout(transaction.consumeLockTimeouts())
return result
} catch (error) {
markRelayDatabaseError(error)
// No ROLLBACK behind a lost reply: it would queue behind the dead statement. Destroying
// the client ends the session, and the server drops its locks.
lostReply = transaction.poisonedBy ?? (isPostgresReadTimeout(error) ? error : undefined)
const open = transaction.open
if (open) await client.query('ROLLBACK').catch(() => undefined)
if (open && !lostReply) await client.query('ROLLBACK').catch(() => undefined)
this.holds.recordUnavailable(transaction.consumeLockUnavailable())
this.holds.recordLockTimeout(transaction.consumeLockTimeouts())
// Once the fused commit has ended the transaction, a retry would apply the work twice.
if (
!open ||
lostReply ||
!retryablePostgresTransactionError(error) ||
attempt === POSTGRES_TRANSACTION_ATTEMPTS
) {
@@ -1169,7 +1309,7 @@ export class PostgresDatabase implements RelayDatabase {
)
}
} finally {
client.release()
client.release(lostReply)
}
// A PostgreSQL transaction is unusable after an abort, so retry all work
// on a fresh pooled client with a small full-jitter delay.
@@ -1223,6 +1363,36 @@ export function readRelayDatabasePoolOldestWaitMs(database: RelayDatabase): numb
return database instanceof PostgresDatabase ? database.poolOldestWaitMs() : 0
}
// pg-pool swaps its own listeners on checkout and release; this one never leaves, so no gap
// between them (or a future pg-pool reordering) can turn a client `error` into a crash. The
// idle and checked-out listeners do the logging.
export function keepPostgresClientErrorsHandled(pool: Pick<pg.Pool, 'on'>): void {
pool.on('connect', (client) => {
client.on('error', () => undefined)
})
}
// An `options=` in the database URL silently replaces the pool's, so the session reports what
// it actually got. The URL itself is never read or logged.
function reportPostgresIdleSessionTimeoutOnce(pool: Pick<pg.Pool, 'on'>): void {
let reported = false
pool.on('connect', (client) => {
if (reported) return
reported = true
client
.query("SELECT current_setting('idle_session_timeout') AS value")
.then((result: pg.QueryResult) => {
console.log(
JSON.stringify({
event: 'orca_relay_postgres_session_settings',
idleSessionTimeout: String(result.rows[0]?.value)
})
)
})
.catch(() => undefined)
})
}
export function absorbPostgresIdleClientErrors(pool: Pick<pg.Pool, 'on'>): void {
pool.on('error', () => {
// Why: node-postgres removes failed idle clients itself; leaving `error`
@@ -1267,6 +1437,7 @@ async function applySchemaOnUntimedPool(
idle_in_transaction_session_timeout: POSTGRES_IDLE_TRANSACTION_TIMEOUT_MS
})
absorbPostgresIdleClientErrors(pool)
keepPostgresClientErrorsHandled(pool)
const database = new PostgresDatabase(pool)
try {
await applyPostgresSchema(
@@ -1298,6 +1469,9 @@ export type RelayDatabaseOpenInput = {
poolMax?: number
applicationName?: string
statementTimeoutMs?: number
readTimeoutMarginMs?: number
// Read per query, so a cell's switch moves the margin without a restart.
readTimeoutMarginOverrideMs?: () => number | undefined
// Directors own the PostgreSQL schema. A cell skips it and never touches the database
// at boot, so it starts listening while the database is down and stays unready until
// its first successful query.
@@ -1311,17 +1485,36 @@ export async function openRelayDatabase(input: RelayDatabaseOpenInput): Promise<
if (appliesPostgresSchema) {
await applySchemaOnUntimedPool(input.databaseUrl, input.applicationName)
}
const statementTimeoutMs = input.statementTimeoutMs ?? relayPostgresStatementTimeoutMs()
const pool = new pg.Pool({
connectionString: input.databaseUrl,
max: input.poolMax ?? 10,
application_name: input.applicationName,
connectionTimeoutMillis: POSTGRES_CONNECTION_TIMEOUT_MS,
statement_timeout: input.statementTimeoutMs ?? relayPostgresStatementTimeoutMs(),
statement_timeout: statementTimeoutMs,
// The server answers every statement within statement_timeout, as 57014 at worst, so
// this fires only on a lost reply, or a database or event-loop stall past the margin.
query_timeout:
statementTimeoutMs + (input.readTimeoutMarginMs ?? relayPostgresReadTimeoutMarginMs()),
lock_timeout: POSTGRES_LOCK_TIMEOUT_MS,
idle_in_transaction_session_timeout: POSTGRES_IDLE_TRANSACTION_TIMEOUT_MS
idle_in_transaction_session_timeout: POSTGRES_IDLE_TRANSACTION_TIMEOUT_MS,
// Bounds a backend whose client was destroyed outside a transaction (after a lost
// reply on an autocommit write or COMMIT). pg-pool closes healthy idle clients at 10 s.
options: `-c idle_session_timeout=${POSTGRES_IDLE_SESSION_TIMEOUT_MS}`
})
absorbPostgresIdleClientErrors(pool)
database = new PostgresDatabase(pool)
keepPostgresClientErrorsHandled(pool)
reportPostgresIdleSessionTimeoutOnce(pool)
const override = input.readTimeoutMarginOverrideMs
database = new PostgresDatabase(
pool,
override
? () => {
const marginMs = override()
return marginMs === undefined ? undefined : statementTimeoutMs + marginMs
}
: undefined
)
} else {
mkdirSync(input.dataDir, { recursive: true })
const sqlite = new DatabaseSync(join(input.dataDir, 'orca-relay.sqlite'))
@@ -0,0 +1,81 @@
import { describe, expect, it, vi } from 'vitest'
import type { RelayConfig } from './config.js'
vi.mock('./admin-token-verifier.js', () => ({
createAdminTokenVerifier: () => async (token: string) => token === 'deploy-token',
createReadOnlyAdminTokenVerifier: () => async () => false,
createRegionalRehomeControlApplyTokenVerifier: () => async () => false,
createRegionalRehomeRuntimeTokenVerifier: () => async () => false,
createRegionalRehomeTokenVerifier: () => async () => false,
createRuntimeTokenVerifier: () => async () => false
}))
vi.mock('./relay-token-verifier.js', () => ({
createRelayTokenVerifier: () => async () => null,
readBearer: (value: string | undefined) => value?.replace(/^Bearer /, '') ?? null
}))
import { createRelayApp } from './app.js'
// The deploy guard reads a director's placement here, so it must be what the director does.
describe('a director reports its reserve placement as built', () => {
const status = async (operations: Record<string, unknown>, overrides: Partial<RelayConfig> = {}) => {
const app = createRelayApp(config(overrides), {
store: {} as never,
assignments: {} as never,
drain: vi.fn(),
ready: vi.fn(async () => true),
...operations
})
const response = await app.request('/v1/admin/runtime-status', {
method: 'POST',
headers: { authorization: 'Bearer deploy-token', 'content-type': 'application/json' },
body: JSON.stringify({ v: 1 })
})
return (await response.json()) as Record<string, unknown>
}
it('says off when placement is configured on but was never built', async () => {
expect(await status({}, { reservePlacement: 'on' })).toMatchObject({ role: 'director', reservePlacement: 'off' })
})
it('says what the built placement does', async () => {
expect(await status({ reservePlacement: { placementMode: 'on' } }, { reservePlacement: 'on' })).toMatchObject({
reservePlacement: 'on'
})
})
it('reports nothing for a cell', async () => {
expect(await status({}, { role: 'cell', cellId: 'production-gce-c1' })).not.toHaveProperty('reservePlacement')
})
})
function config(overrides: Partial<RelayConfig> = {}): RelayConfig {
return {
port: 8080,
publicUrl: 'https://relay.example.test',
cellUrl: 'https://relay.example.test',
region: 'us-central1',
authIssuer: 'https://auth.example.test',
authAudience: 'orca-relay',
jwksUrl: 'https://auth.example.test/jwks',
assignmentSigningKey: new Uint8Array(32),
role: 'director',
cellId: 'director',
cells: [],
adminAudience: 'https://relay.example.test/v1/admin/drain',
deployServiceAccount: 'deploy@example.test',
runtimeServiceAccount: 'relay-cell@example.test',
adminJwksUrl: 'https://auth.example.test/jwks',
databasePoolMax: 10,
publicAssignmentsEnabled: true,
publicAssignmentConcurrency: 2,
publicAssignmentQueueMax: 128,
publicAssignmentWaitMs: 4_000,
publicResolveConcurrency: 1,
publicResolveWaitMs: 5_000,
publicAssignmentRetryAfterSeconds: 5,
dataDir: './data',
...overrides
}
}
@@ -12,8 +12,12 @@ const NEVER_REJECTS = new Map([
['relay-background-operation.ts#runRelayBackgroundOperation', 'catches and logs every failure'],
['assignment-cleanup-steps.ts#runAssignmentCleanup', 'runs each step through the above'],
['cell-heartbeat-client.ts#send', 'one try/catch around the whole send'],
['cell-reserve-client.ts#send', 'one try/catch around the request; resolves every item'],
['cell-reserve-client.ts#demote', 'one try/catch around the request'],
['reserve-ledger-writer.ts#flush', 'one try/catch around the write; retries with backoff'],
['control-renewal-batch.ts#flush', 'rejects the waiters, never itself'],
['regional-rehome-worker.ts#run', 'one try/catch around the whole poll']
['regional-rehome-worker.ts#run', 'one try/catch around the whole poll'],
['relay-control-flag-channel.ts#poll', 'one catch on the whole read']
])
const sourceDirectory = fileURLToPath(new URL('.', import.meta.url))
@@ -20,3 +20,24 @@ export async function googleMetadataIdentityToken(
}
return token
}
// Google identity tokens live an hour; minting one per request would be a metadata read each.
// A failed mint is never reused.
export function reusedIdentityToken(
mint: () => Promise<string>,
now: () => number = Date.now,
reuseMs = 10 * 60_000
): () => Promise<string> {
let token: { value: Promise<string>; at: number } | undefined
return () => {
const at = now()
if (!token || at - token.at > reuseMs) {
const value = mint()
token = { value, at }
value.catch(() => {
if (token?.value === value) token = undefined
})
}
return token.value
}
}
+516 -18
View File
@@ -28,7 +28,13 @@ import nacl from 'tweetnacl'
import type WebSocket from 'ws'
import type { RawData } from 'ws'
import type { RelayConfig } from './config.js'
import type { AssignmentLeaseClass } from './assignment-lease.js'
import type { AssignmentLeaseShadow } from './assignment-lease-shadow.js'
import type { RelayAssignmentStore } from './assignment-store.js'
import type { CellAdmitMode, CellReserveBook } from './cell-reserve-book.js'
import type { DemoteRequest, ReserveOutcome, ReserveRequest } from './cell-reserve-contract.js'
import { CellSeatLog, type CellSeatChange, type CellSeatFeedPage } from './cell-seat-log.js'
import { isRelayDatabaseTransientError } from './database.js'
import { ControlRenewalBatch } from './control-renewal-batch.js'
import { RelayCredentialStore, type CredentialReservation } from './credential-store.js'
import { HostCloseReasonMemory } from './host-close-reason-memory.js'
@@ -84,7 +90,8 @@ export type HostSession = {
readonly relayHostId: string
readonly generation: number
assignmentEpoch: number
readonly controlActivityId: string | null
// Null for a control admitted from memory until a flip back registers it.
controlActivityId: string | null
readonly controlResumeSecret: string
// Why: reconnect churn is only actionable once it can be pinned to a client build.
// Refreshed on rebind so it describes the socket that closed, not the first one.
@@ -203,6 +210,51 @@ const ACTIVATION_QUEUE_WAIT_MS = 30_000
export const CONTROL_LEASE_MS = 6 * 60 * 60 * 1000
export const CONTROL_LEASE_JITTER_MS = 30 * 60 * 1000
// Step 5 on a cell: what the per-cell switch says, and the booking book it admits from.
export type CellReserveAdmission = {
mode: () => CellAdmitMode
ticketEnforce: () => boolean
dryRunEnabled: () => boolean
// A real booking: a placing director is there (the dead-man's evidence).
directorContact?: () => void
// The switch file's flip-back pace, when set.
reregisterInFlight?: () => number | undefined
// The pool-pressure shed's condition: a hello memory cannot admit is shed, not queued.
databaseShedding: () => boolean
book: CellReserveBook
verifyLease: (input: {
lease: string | undefined
userId: string
relayHostId: string
helloEpoch: number
}) => Promise<AssignmentLeaseClass>
}
// A leaver that closed for these reasons was moved off this cell; it may not rejoin from memory.
const NO_REJOIN_CLOSE_CODES: ReadonlySet<number> = new Set([
RELAY_CLOSE_CODE.DRAINING,
RELAY_CLOSE_CODE.WRONG_CELL
])
// A demoted duplicate keeps its splices this long, then closes so the desktop re-assigns.
export const DEMOTION_CLOSE_MS = 60_000
// A flip back registers memory-admitted controls with at most this share of the pool in
// flight (US 10 -> 3, Asia 16 -> 5), so DB-path hellos and renewals keep the rest.
export const REREGISTER_POOL_SHARE = 1 / 3
const REREGISTER_TICK_MS = 100
// A row the ledger has not written yet gets this long; then the control is closed (4409).
export const REREGISTER_MAX_ATTEMPTS = 20
// An endless database retry stays visible: one line per this many consecutive stalls.
export const REREGISTER_STALL_LOG_EVERY = 20
// The switch-file override never takes the last two connections hellos and renewals need.
export function reregisterInFlightLimit(flag: number | undefined, databasePoolMax: number): number {
if (flag === undefined) return Math.max(1, Math.floor(databasePoolMax * REREGISTER_POOL_SHARE))
return Math.max(1, Math.min(flag, databasePoolMax - 2))
}
// `attempts` counts refusals from a database that answered; `stalls` paces retries of one that did not.
type ReregistrationEntry = { key: string; generation: number; attempts: number; stalls?: number; dueAt: number }
export class HostSessionRegistry {
private readonly sessions = new Map<string, HostSession>()
private readonly activationQueues = new Map<string, Promise<void>>()
@@ -216,6 +268,20 @@ export class HostSessionRegistry {
// Hosts whose drain has been sent. Paced sends land minutes apart, so "this cell is
// draining" is not the same question as "this host has been told to leave".
private readonly drainSentHosts = new Set<string>()
private readonly seatLog: CellSeatLog
// Seat key -> the demoted control socket. Its own set, not drainSentHosts: it ends with
// that socket (close, rebind or a new join), so a later booking here is a fresh seat.
private readonly demotedSeats = new Map<string, WebSocket>()
private reregistration: {
queue: ReregistrationEntry[]
// Sessions already queued, given up on, or done in this pass: the rescan skips them.
seen: Set<string>
inFlight: number
// relay_cells delta not yet written: one write per tick folds every activation's.
cellDelta: number
cellWriteInFlight: boolean
timer: ReturnType<typeof setInterval>
} | null = null
private readonly idleWork = new Map<string, number>()
private readonly idleAttempts = new Map<
@@ -341,8 +407,324 @@ export class HostSessionRegistry {
private readonly observer: RelayRuntimeObserver,
private readonly now: () => number = Date.now,
private readonly random: () => number = Math.random,
private readonly cellIncarnation?: string
) {}
private readonly cellIncarnation?: string,
private readonly assignmentLeaseShadow?: AssignmentLeaseShadow,
private readonly reserveAdmission?: CellReserveAdmission
) {
this.seatLog = new CellSeatLog(undefined, now)
}
// Reserve mode is only ever a cell's, and only while its applied switch says so.
private reserveMode(): boolean {
return this.config.role === 'cell' && this.reserveAdmission?.mode() === 'reserve'
}
inReserveMode(): boolean {
return this.reserveMode()
}
// Leaving reserve mode: every control admitted from memory gets the database lease it never
// took, while it stays connected. Rescans until none lacks one, so a hello whose proof lands
// after the flip is caught too. A control whose row never names this cell is closed (4409).
reregisterMemoryControls(): number {
if (this.config.role !== 'cell') return 0
if (this.reregistration) clearInterval(this.reregistration.timer)
this.reregistration = null
const state: NonNullable<HostSessionRegistry['reregistration']> = {
queue: [],
seen: new Set(),
inFlight: 0,
cellDelta: 0,
cellWriteInFlight: false,
timer: setInterval(() => this.reregisterTick(state), REREGISTER_TICK_MS)
}
state.timer.unref?.()
const queued = this.rescanUnleasedControls(state)
this.reregistration = state
console.warn(
JSON.stringify({
event: 'orca_relay_cell_reregistration_started',
...this.logIdentity(),
controls: queued
})
)
return queued
}
reregistrationPending(): number {
const state = this.reregistration
return state ? state.queue.length + state.inFlight + (state.cellDelta === 0 ? 0 : 1) : 0
}
private rescanUnleasedControls(state: NonNullable<HostSessionRegistry['reregistration']>): number {
const now = this.now()
let queued = 0
for (const [key, session] of this.sessions) {
if (session.controlActivityId !== null || session.state === 'closed' || !session.socket) continue
const seenKey = `${key}\u0000${session.generation}`
if (state.seen.has(seenKey)) continue
state.seen.add(seenKey)
state.queue.push({ key, generation: session.generation, attempts: 0, dueAt: now })
queued += 1
}
return queued
}
private reregisterTick(state: NonNullable<HostSessionRegistry['reregistration']>): void {
// Back in reserve mode, the remaining controls need no lease again.
if (this.reserveMode() || this.reregistration !== state) {
clearInterval(state.timer)
if (this.reregistration === state) this.reregistration = null
return
}
this.flushReregistrationCellDelta(state)
const now = this.now()
const maxInFlight = reregisterInFlightLimit(
this.reserveAdmission?.reregisterInFlight?.(),
this.config.databasePoolMax
)
while (state.inFlight < maxInFlight) {
const index = state.queue.findIndex((entry) => entry.dueAt <= now)
if (index < 0) break
const [entry] = state.queue.splice(index, 1)
this.reregisterOne(state, entry!)
}
if (state.queue.length > 0 || state.inFlight > 0) return
if (this.rescanUnleasedControls(state) > 0) return
if (state.cellDelta !== 0 || state.cellWriteInFlight) return
clearInterval(state.timer)
this.reregistration = null
console.warn(
JSON.stringify({ event: 'orca_relay_cell_reregistration_finished', ...this.logIdentity() })
)
}
private flushReregistrationCellDelta(state: NonNullable<HostSessionRegistry['reregistration']>): void {
if (state.cellDelta === 0 || state.cellWriteInFlight) return
const delta = state.cellDelta
state.cellDelta = 0
state.cellWriteInFlight = true
void this.assignments
.commitCellReservationDelta(this.config.cellId, delta)
.catch(() => {
// Retried next tick: the leases exist, only the cell's count is behind.
state.cellDelta += delta
})
.finally(() => {
state.cellWriteInFlight = false
})
}
private reregisterOne(
state: NonNullable<HostSessionRegistry['reregistration']>,
entry: ReregistrationEntry
): void {
const session = this.sessions.get(entry.key)
if (
!session ||
session.state === 'closed' ||
session.generation !== entry.generation ||
session.controlActivityId !== null
) {
return
}
const identity = { userId: session.identity.sub, relayHostId: session.relayHostId }
const epoch = session.assignmentEpoch
state.inFlight += 1
void this.assignments
.activateControlDeferringCell(identity, {
cellId: this.config.cellId,
assignmentEpoch: epoch,
generation: session.generation,
idleRegionalRehome:
(session.socket &&
this.hostCapabilities.get(session.socket)?.has(RELAY_HOST_CAPABILITY_IDLE_REGIONAL_REHOME)) ??
false,
cellIncarnation: this.cellIncarnation
})
.then(async ({ activityId, reservationDelta }) => {
state.cellDelta += reservationDelta
// As a database-path join does: an open migration onto this cell sees its host arrive.
try {
await this.assignments.markMigrationTargetRegistered(identity, {
cellId: this.config.cellId,
assignmentEpoch: epoch
})
} catch (error) {
this.releaseActivityBestEffort(identity, activityId)
throw error
}
return activityId
})
.then((activityId) => {
const current = this.sessions.get(entry.key)
if (
current !== session ||
session.state === 'closed' ||
session.generation !== entry.generation ||
session.assignmentEpoch !== epoch ||
this.reserveMode()
) {
// The control moved on while the lease was taken; it must not stay leased.
this.releaseActivityBestEffort(identity, activityId)
return
}
session.controlActivityId = activityId
session.activityRenewalDueAt = this.now() + RELAY_PROTOCOL_LIMITS.controlPingIntervalMs
})
.catch((error: unknown) => {
if (this.reregistration !== state) return
const socket = session.socket
const current =
this.sessions.get(entry.key) === session &&
session.generation === entry.generation &&
socket?.readyState === socket?.OPEN
// A database that did not answer (a stall, a dropped connection, a busy lock) is retried
// for as long as the control is open: a fleet-wide flip back must not close everyone over
// a slow database. An answer that refuses (a row naming another seat, which may still be
// the ledger's write on its way, or any other refusal) counts toward giving up. A lease
// insert that lost a race (23505) is retried too: the next try reads the winner's row.
const unanswered =
isRelayDatabaseTransientError(error) ||
(error instanceof Error && error.message === 'database_lock_unavailable') ||
(error as { code?: unknown } | null)?.code === '23505'
if (unanswered) {
if (!current) return
entry.stalls = (entry.stalls ?? 0) + 1
if (entry.stalls % REREGISTER_STALL_LOG_EVERY === 0) {
console.warn(
JSON.stringify({
event: 'orca_relay_cell_reregistration_stalled',
...this.logIdentity(),
relayHostIdDigest: relayHostLogDigest(session.relayHostId),
stalls: entry.stalls,
reason: error instanceof Error ? error.message : 'unknown'
})
)
}
entry.dueAt = this.now() + Math.min(5_000, 250 * 2 ** Math.min(entry.stalls, 5))
state.queue.push(entry)
return
}
entry.stalls = 0
entry.attempts += 1
if (entry.attempts < REREGISTER_MAX_ATTEMPTS) {
entry.dueAt = this.now() + Math.min(5_000, 250 * 2 ** entry.attempts)
state.queue.push(entry)
return
}
console.warn(
JSON.stringify({
event: 'orca_relay_cell_reregistration_gave_up',
...this.logIdentity(),
relayHostIdDigest: relayHostLogDigest(session.relayHostId),
assignmentEpoch: epoch,
reason: error instanceof Error ? error.message : 'unknown'
})
)
// Its row names another seat: re-assign instead of serving unleased.
if (current && socket) socket.close(RELAY_CLOSE_CODE.WRONG_CELL, 'control could not re-register')
})
.finally(() => {
state.inFlight -= 1
})
}
reserveCounts(): { bookings: number; intake: { perSec: number; burst: number; tokens: number } } | null {
const book = this.reserveAdmission?.book
return book ? { bookings: book.count(), intake: book.intakeSnapshot() } : null
}
reserve(request: ReserveRequest): ReserveOutcome[] {
const admission = this.reserveAdmission
if (this.config.role !== 'cell' || !admission || (request.dryRun && !admission.dryRunEnabled())) {
return request.items.map(() => ({ outcome: 'off' }))
}
if (!request.dryRun) admission.directorContact?.()
const context = {
mode: admission.mode(),
draining: this.draining,
seatedEpoch: (userId: string, relayHostId: string) => {
const seat = this.seatLog.seatOf(userId, relayHostId)
return seat && !this.demotedSeats.has(this.key(userId, relayHostId)) ? seat.epoch : undefined
}
}
return request.items.map((item) => {
const outcome = admission.book.reserve(request.directorId, item, context, request.dryRun)
if (outcome.outcome === 'ok' && !request.dryRun) {
this.seatLog.append({
kind: 'reserve',
userId: item.userId,
relayHostId: item.relayHostId,
epoch: item.epoch,
generation: 0,
reservedBy: request.directorId,
at: this.now()
})
}
return outcome
})
}
// The upgrade's view: a booked host claims its unit up to the hard cap, and neither a booked
// nor a recently seated host needs the database, so the pool-pressure shed skips them.
reserveAdmissionFor(identity: RelayIdentityKey): 'booked' | 'seated' | null {
if (!this.reserveMode()) return null
if (this.reserveAdmission!.book.has(identity.userId, identity.relayHostId)) return 'booked'
const seat = this.seatLog.seatOf(identity.userId, identity.relayHostId)
const left = this.seatLog.recentlyLeftOf(identity.userId, identity.relayHostId)
return seat || (left && !NO_REJOIN_CLOSE_CODES.has(left.closeCode ?? 0)) ? 'seated' : null
}
// Rule 2: seated here at this epoch, or left here at it within 10 minutes for a reason that
// did not move the host away.
private seatedHereAt(userId: string, relayHostId: string, epoch: number): boolean {
if (this.drainSentHosts.has(relayHostId)) return false
if (this.demotedSeats.has(this.key(userId, relayHostId))) return false
const seat = this.seatLog.seatOf(userId, relayHostId)
if (seat) return seat.epoch === epoch
const left = this.seatLog.recentlyLeftOf(userId, relayHostId)
return left !== undefined && left.epoch === epoch && !NO_REJOIN_CLOSE_CODES.has(left.closeCode ?? 0)
}
// A director names this seat the loser of a duplicate: stop taking phones for it now, and
// close it once its splices have had time to finish. Repeats are one demotion.
demote(request: DemoteRequest): 'demoted' | 'already-demoted' | 'not-seated' | 'off' {
// Only a reserve-mode cell takes demotions, so flipping admitMode back reverts the route.
if (!this.reserveMode()) return 'off'
const session = this.get(request)
const key = this.key(request.userId, request.relayHostId)
// The seat the director saw: the same host rejoining here later is a different seat.
const seat = this.seatLog.seatOf(request.userId, request.relayHostId)
if (
!session ||
session.state === 'closed' ||
!session.socket ||
session.assignmentEpoch !== request.epoch ||
seat?.joinedAt !== request.joinedAt
) {
return 'not-seated'
}
const socket = session.socket
if (this.demotedSeats.get(key) === socket) return 'already-demoted'
this.demotedSeats.set(key, socket)
this.markSeatDrainOnly(session)
const timer = setTimeout(() => {
if (this.demotedSeats.get(key) === socket) {
socket.close(RELAY_CLOSE_CODE.WRONG_CELL, 'seated on a newer cell')
}
}, DEMOTION_CLOSE_MS)
timer.unref?.()
console.warn(
JSON.stringify({
event: 'orca_relay_cell_seat_demoted',
...this.logIdentity(),
relayHostIdDigest: relayHostLogDigest(session.relayHostId),
assignmentEpoch: request.epoch
})
)
return 'demoted'
}
// Renewals leave the heartbeat as an enqueue: one statement per cell per
// window replaces one write transaction per host, which is what keeps the
@@ -452,6 +834,12 @@ export class HostSessionRegistry {
markStage('credential')
const sessionKey = this.key(reservation.userId, hostId)
const session = this.sessions.get(sessionKey)
if (session?.socket && this.demotedSeats.get(sessionKey) === session.socket) {
capacityReservation?.release()
await this.store.failReservation(reservation)
this.rejectClient(socket, RELAY_CLOSE_CODE.DRAINING)
return
}
if (
!session ||
session.state !== 'active' ||
@@ -811,7 +1199,8 @@ export class HostSessionRegistry {
socket: WebSocket,
identity: RelayTokenClaims,
connectionInclusionWatermark?: number,
hostCapabilities?: ReadonlySet<string>
hostCapabilities?: ReadonlySet<string>,
assignmentLease?: string
): void {
if (this.idleAttempts.has(identity.relayHostId)) {
socket.close(RELAY_CLOSE_CODE.WRONG_CELL, 'idle cutover in progress')
@@ -835,7 +1224,8 @@ export class HostSessionRegistry {
socket,
identity,
payload(raw, 'host-hello'),
connectionInclusionWatermark
connectionInclusionWatermark,
assignmentLease
),
socket,
'host hello proof'
@@ -900,6 +1290,8 @@ export class HostSessionRegistry {
drain(graceMs: number, options: { paceWindowMs?: number } = {}): void {
this.draining = true
// A draining cell admits no booking, so the units they hold go back now.
this.reserveAdmission?.book.clear()
// A later drain (an emergency one, or shutdown) owns every session again, so nothing
// queued by an earlier paced drain may still fire: it would re-send and, worse, keep
// the event loop alive for the rest of a window the operator just cut short.
@@ -925,7 +1317,7 @@ export class HostSessionRegistry {
private sendDrain(session: HostSession, graceMs: number): void {
if (session.state === 'closed') return
session.authorityRevision += 1
session.state = 'drain-only'
this.markSeatDrainOnly(session)
this.drainSentHosts.add(session.relayHostId)
if (session.socket) send(session.socket, 'drain', { graceMs, recovery: 'resolve-director' })
this.scheduleDrainTimer(graceMs, () => this.closeDrainedSession(session))
@@ -977,7 +1369,8 @@ export class HostSessionRegistry {
socket: WebSocket,
identity: RelayTokenClaims,
candidate: unknown,
connectionInclusionWatermark?: number
connectionInclusionWatermark?: number,
assignmentLease?: string
): Promise<void> {
const hello = HostHelloSchema.safeParse(candidate)
const hostPublicKey = hello.success
@@ -1001,16 +1394,64 @@ export class HostSessionRegistry {
socket.close(RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL, 'host key binding mismatch')
return
}
const leaseShadow = this.config.role === 'cell' ? this.assignmentLeaseShadow : undefined
const leaseCheck =
leaseShadow?.check({
lease: assignmentLease,
userId: identity.sub,
relayHostId: identity.relayHostId,
helloEpoch: hello.data.assignmentEpoch
}) ?? null
// Reserve mode admits, in order, a booking, a seat this cell remembers, and (dark) a valid
// lease; anything else falls to today's database check. The mode is read once per hello.
const reserveMode = this.reserveMode()
let reservedBy: string | undefined
let admittedFromMemory = false
if (reserveMode) {
const booking = this.reserveAdmission!.book.take(
identity.sub,
identity.relayHostId,
hello.data.assignmentEpoch
)
reservedBy = booking?.directorId
admittedFromMemory =
booking !== null ||
this.seatedHereAt(identity.sub, identity.relayHostId, hello.data.assignmentEpoch) ||
(this.reserveAdmission!.ticketEnforce() &&
(await this.reserveAdmission!.verifyLease({
lease: assignmentLease,
userId: identity.sub,
relayHostId: identity.relayHostId,
helloEpoch: hello.data.assignmentEpoch
})) === 'valid')
}
if (reserveMode && !admittedFromMemory && this.reserveAdmission!.databaseShedding()) {
// Memory cannot admit it (e.g. seated here, but at another epoch), and the wedged pool
// would only time out its database check: shed it now, as the upgrade shed does.
this.observer.recordAuth(false)
socket.close(RELAY_CLOSE_CODE.LIMIT_EXCEEDED, 'relay temporarily unavailable')
return
}
const assignmentReadStartedAt = performance.now()
// Combined is staging-only compatibility; stamped cells require the durable director epoch.
const assignmentValid =
this.config.role === 'combined'
admittedFromMemory ||
(this.config.role === 'combined'
? hello.data.assignmentEpoch === 1
: await this.assignments.verifyCellAssignment({
userId: identity.sub,
relayHostId: identity.relayHostId,
cellId: this.config.cellId,
assignmentEpoch: hello.data.assignmentEpoch
})
}))
// The database answer decides; the lease is only compared with it.
if (leaseShadow && leaseCheck && !admittedFromMemory) {
leaseShadow.record(
leaseCheck,
assignmentValid,
performance.now() - assignmentReadStartedAt
)
}
if (!assignmentValid) {
this.observer.recordAuth(false)
socket.close(RELAY_CLOSE_CODE.WRONG_CELL, 'wrong assignment epoch')
@@ -1088,7 +1529,8 @@ export class HostSessionRegistry {
rebind,
hello.data.assignmentEpoch,
hello.data.appVersion,
connectionInclusionWatermark
connectionInclusionWatermark,
admittedFromMemory ? { reservedBy } : undefined
),
socket,
'host activation'
@@ -1104,7 +1546,9 @@ export class HostSessionRegistry {
rebind: boolean,
assignmentEpoch: number,
appVersion: string,
connectionInclusionWatermark?: number
connectionInclusionWatermark?: number,
// Present when the hello was admitted from memory: no database registration.
reserved?: { reservedBy?: string }
): Promise<void> {
const release = this.beginIdleWork(identity.relayHostId)
if (!release) {
@@ -1138,7 +1582,8 @@ export class HostSessionRegistry {
rebind,
assignmentEpoch,
appVersion,
connectionInclusionWatermark
connectionInclusionWatermark,
reserved
)
})
this.activationQueues.set(key, activation)
@@ -1158,10 +1603,12 @@ export class HostSessionRegistry {
rebind: boolean,
assignmentEpoch: number,
appVersion: string,
connectionInclusionWatermark?: number
connectionInclusionWatermark?: number,
reserved?: { reservedBy?: string }
): Promise<void> {
let controlActivityId: string | null = null
if (this.config.role === 'cell') {
// Admitted from memory, but the cell left reserve mode before the proof landed: lease now.
if (this.config.role === 'cell' && !(reserved && this.reserveMode())) {
try {
controlActivityId = await this.assignments.activateControl(
{ userId: identity.sub, relayHostId: identity.relayHostId },
@@ -1212,6 +1659,9 @@ export class HostSessionRegistry {
existing.orphanTimer = null
existing.authorityRevision += 1
existing.assignmentEpoch = assignmentEpoch
// A memory-admitted control rebound through the database now holds a lease (the same
// id today, since a rebind keeps its generation).
if (controlActivityId) existing.controlActivityId = controlActivityId
existing.socket = socket
existing.state = existing.regionalDrainAttemptId ? 'drain-only' : 'active'
existing.appVersion = appVersion
@@ -1221,6 +1671,8 @@ export class HostSessionRegistry {
existing.activityRenewalDueAt = this.now() + RELAY_PROTOCOL_LIMITS.controlPingIntervalMs
this.wireActiveControl(existing)
this.sendHelloAck(existing)
this.demotedSeats.delete(this.key(identity.sub, identity.relayHostId))
this.appendSeatChange(existing, 'join', undefined, reserved?.reservedBy)
if (existing.regionalDrainAttemptId) this.reassertRegionalDrain(existing)
previousSocket?.close(RELAY_CLOSE_CODE.PEER_DROPPED, 'control rebound')
return
@@ -1290,8 +1742,49 @@ export class HostSessionRegistry {
// A host that proved itself again is not signed out, whatever it said last.
this.hostCloseReasons.forget(sessionKey)
this.sessions.set(sessionKey, session)
this.demotedSeats.delete(sessionKey)
this.wireActiveControl(session)
this.sendHelloAck(session)
this.appendSeatChange(session, 'join', undefined, reserved?.reservedBy)
}
seatFeed(sinceSeq: number | null): CellSeatFeedPage {
return { ...this.seatLog.read(sinceSeq), seats: this.seatLog.seatCount() }
}
private appendSeatChange(
session: HostSession,
kind: CellSeatChange['kind'],
closeCode?: number,
reservedBy?: string
): void {
this.seatLog.append({
kind,
userId: session.identity.sub,
relayHostId: session.relayHostId,
epoch: session.assignmentEpoch,
generation: session.generation,
...(kind === 'join' && (session.state === 'active' || session.state === 'drain-only')
? { state: session.state }
: {}),
...(closeCode === undefined ? {} : { closeCode }),
...(reservedBy === undefined ? {} : { reservedBy }),
at: this.now()
})
}
// Only a seated host's transition is a feed change; an orphaned one already left.
private markSeatDrainOnly(session: HostSession): void {
const changed = session.state !== 'drain-only'
session.state = 'drain-only'
if (changed && session.socket) this.appendSeatChange(session, 'drain-only')
}
// A refreshed token lifts an auth-expiry drain-only; a regional drain is never lifted here.
private markSeatActive(session: HostSession): void {
const changed = session.state !== 'active'
session.state = 'active'
if (changed && session.socket) this.appendSeatChange(session, 'active')
}
private wireActiveControl(session: HostSession): void {
@@ -1313,7 +1806,12 @@ export class HostSessionRegistry {
// Guarded on identity: a predecessor retired by a rebind must not stamp a
// cause onto the live session that replaced it.
if (session.socket === socket) {
this.hostCloseReasons.record(this.key(session.identity.sub, session.relayHostId), reason)
const seatKey = this.key(session.identity.sub, session.relayHostId)
this.hostCloseReasons.record(seatKey, reason)
// However it closed, a demoted seat was moved: it may not rejoin here from memory.
const demoted = this.demotedSeats.get(seatKey) === socket
if (demoted) this.demotedSeats.delete(seatKey)
this.appendSeatChange(session, 'leave', demoted ? RELAY_CLOSE_CODE.WRONG_CELL : code)
}
// One line per control close makes reconnect churners attributable by
// host digest without exposing the raw relay host id.
@@ -1402,7 +1900,7 @@ export class HostSessionRegistry {
return
}
session.identity = refreshed
if (!session.regionalDrainAttemptId) session.state = 'active'
if (!session.regionalDrainAttemptId) this.markSeatActive(session)
}
private heartbeat(session: HostSession): void {
@@ -1524,7 +2022,7 @@ export class HostSessionRegistry {
session.socket.close(RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL, 'relay authorization expired')
return
}
if (now > expiresAt) session.state = 'drain-only'
if (now > expiresAt) this.markSeatDrainOnly(session)
if (now > session.leaseExpiresAt) {
send(session.socket, 'drain', { graceMs: 0, recovery: 'resolve-director' })
session.socket.close(RELAY_CLOSE_CODE.DRAINING, 'control lease expired')
@@ -1618,7 +2116,7 @@ export class HostSessionRegistry {
}
private reassertRegionalDrain(session: HostSession): void {
session.state = 'drain-only'
this.markSeatDrainOnly(session)
if (!session.socket) return
send(session.socket, 'drain', {
graceMs: Math.max(0, (session.regionalDrainExpiresAt ?? this.now()) - this.now()),
@@ -0,0 +1,302 @@
import { describe, expect, it, vi } from 'vitest'
import type { HostWhereaboutsRow } from './assignment-store.js'
import { RelayAssignmentStore } from './assignment-store.js'
import type { RelayConfig } from './config.js'
import { openInMemoryRelayDatabase } from './database.js'
import { ShadowSeatDirectory } from './shadow-seat-directory.js'
vi.mock('./admin-token-verifier.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('./admin-token-verifier.js')>()
const identities = new Map<string, Parameters<typeof actual.relayAdminIdentityMayAccess>[0]>([
['deploy-token', 'deploy'],
['monitor-token', 'monitor'],
['fence-token', 'fence'],
['capacity-token', 'capacity']
])
return {
...actual,
// The real route allow-list behind a token-to-identity stub.
createAdminTokenVerifier: () => async (token: string, route?: string) => {
const identity = identities.get(token)
if (identity === undefined) return false
return !route || actual.relayAdminIdentityMayAccess(identity, route)
},
createReadOnlyAdminTokenVerifier: () => async () => false,
createRegionalRehomeControlApplyTokenVerifier: () => async () => false,
createRegionalRehomeRuntimeTokenVerifier: () => async () => false,
createRegionalRehomeTokenVerifier: () => async () => false,
createRuntimeTokenVerifier: () => async () => false
}
})
vi.mock('./relay-token-verifier.js', () => ({
createRelayTokenVerifier: () => async () => null,
readBearer: (value: string | undefined) => value?.replace(/^Bearer /, '') ?? null
}))
import { createRelayApp } from './app.js'
const HOST = 'hhhhhhhhhhhhhhhh'
const ROW: HostWhereaboutsRow = {
cellId: 'cell-a',
assignmentEpoch: 3,
leaseExpiresAt: 5_000,
openMigration: null
}
function request(body: unknown, token = 'deploy-token'): RequestInit {
return {
method: 'POST',
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
body: JSON.stringify(body)
}
}
function directory(): ShadowSeatDirectory {
const shadow = new ShadowSeatDirectory()
shadow.setCells(['cell-a', 'cell-b'])
shadow.apply(
'cell-a',
{
v: 1,
cellId: 'cell-a',
incarnation: 'inc-a',
seq: 1,
at: 1,
flagsApplied: { generation: '7', flags: { readinessLocal: false } },
full: [
{
userId: 'user-a',
relayHostId: HOST,
epoch: 3,
generation: 1,
state: 'active',
joinedAt: 1
}
]
},
Date.now()
)
shadow.markNoFeed('cell-b', Date.now())
return shadow
}
function appWith(
hostWhereabouts: (identity: { userId: string; relayHostId: string }) => Promise<unknown>,
options: { shadowSeats?: ShadowSeatDirectory; role?: RelayConfig['role'] } = {}
) {
return createRelayApp(config(options.role), {
store: {} as never,
// SAFETY: the whereabouts route reads only hostWhereabouts.
assignments: { hostWhereabouts } as never,
drain: vi.fn(),
ready: vi.fn(async () => true),
cellIncarnation: 'director-incarnation',
shadowSeats: options.shadowSeats
})
}
describe('POST /v1/admin/host-whereabouts', () => {
it('requires admin auth and a director', async () => {
const lookup = vi.fn(async () => ROW)
const app = appWith(lookup)
const body = { v: 1, relayHostId: HOST, userId: 'user-a' }
expect((await app.request('/v1/admin/host-whereabouts', request(body, 'other'))).status).toBe(
401
)
const cell = appWith(lookup, { role: 'cell' })
expect((await cell.request('/v1/admin/host-whereabouts', request(body))).status).toBe(404)
expect(
(await app.request('/v1/admin/host-whereabouts', request({ ...body, extra: 1 }))).status
).toBe(400)
expect(lookup).not.toHaveBeenCalled()
})
it('lets the monitor and fence accounts read it, and not the capacity account', async () => {
const app = appWith(async () => ROW)
const body = { v: 1, relayHostId: HOST, userId: 'user-a' }
for (const token of ['deploy-token', 'monitor-token', 'fence-token']) {
const response = await app.request('/v1/admin/host-whereabouts', request(body, token))
expect(response.status).toBe(200)
}
const capacity = await app.request(
'/v1/admin/host-whereabouts',
request(body, 'capacity-token')
)
expect(capacity.status).toBe(401)
})
it('returns the database row, the map seats, the verdict and the named cells', async () => {
const lookup = vi.fn(async () => ROW)
const app = appWith(lookup, { shadowSeats: directory() })
const response = await app.request(
'/v1/admin/host-whereabouts',
request({ v: 1, relayHostId: HOST, userId: 'user-a' })
)
expect(response.status).toBe(200)
expect(lookup).toHaveBeenCalledWith({ userId: 'user-a', relayHostId: HOST })
expect(await response.json()).toMatchObject({
v: 1,
directorIncarnation: 'director-incarnation',
relayHostId: HOST,
shadow: { state: 'on', complete: true },
users: [
{
userId: 'user-a',
database: ROW,
seats: [{ cellId: 'cell-a', epoch: 3, state: 'active' }],
recentlyLeft: [],
verdict: { class: 'agree', explained: true }
}
],
cells: {
'cell-a': {
status: 'live',
lastFailure: null,
flagsApplied: { generation: '7', flags: { readinessLocal: false } }
}
}
})
})
it('names the user from the map when the request has only a host id', async () => {
const lookup = vi.fn(async () => ROW)
const app = appWith(lookup, { shadowSeats: directory() })
const response = await app.request(
'/v1/admin/host-whereabouts',
request({ v: 1, relayHostId: HOST })
)
expect(lookup).toHaveBeenCalledWith({ userId: 'user-a', relayHostId: HOST })
expect(await response.json()).toMatchObject({ users: [{ userId: 'user-a', database: ROW }] })
})
it('judges the verdict as /v1/resolve would when the row names a dead cell', async () => {
const shadow = directory()
// cell-a's heartbeat had already run out when the cell list was read.
shadow.setCells(['cell-a', 'cell-b'], ['cell-a', 'cell-b'], {
readAt: Date.now(),
expiresAt: new Map([
['cell-a', Date.now() - 1],
['cell-b', Date.now() + 60_000]
])
})
const app = appWith(async () => ROW, { shadowSeats: shadow })
const response = await app.request(
'/v1/admin/host-whereabouts',
request({ v: 1, relayHostId: HOST, userId: 'user-a' })
)
expect(await response.json()).toMatchObject({
users: [{ database: ROW, verdict: { class: 'map-only-cell-unlive', explained: true } }]
})
})
it('answers an unknown host with an empty result, not a 404', async () => {
const lookup = vi.fn(async () => null)
const shadowOff = appWith(lookup)
const unknown = await shadowOff.request(
'/v1/admin/host-whereabouts',
request({ v: 1, relayHostId: 'uuuuuuuuuuuuuuuu' })
)
expect(unknown.status).toBe(200)
expect(await unknown.json()).toEqual({
v: 1,
directorIncarnation: 'director-incarnation',
relayHostId: 'uuuuuuuuuuuuuuuu',
shadow: { state: 'off' },
users: [],
cells: {}
})
expect(lookup).not.toHaveBeenCalled()
const named = await shadowOff.request(
'/v1/admin/host-whereabouts',
request({ v: 1, relayHostId: 'uuuuuuuuuuuuuuuu', userId: 'user-z' })
)
expect(await named.json()).toMatchObject({
users: [{ userId: 'user-z', database: null, seats: [], recentlyLeft: [], verdict: null }]
})
})
it('answers a briefly unreachable database with a retryable 503', async () => {
const app = appWith(async () => {
throw new Error('Connection terminated due to connection timeout')
})
const response = await app.request(
'/v1/admin/host-whereabouts',
request({ v: 1, relayHostId: HOST, userId: 'user-a' })
)
expect(response.status).toBe(503)
})
})
describe('RelayAssignmentStore.hostWhereabouts', () => {
it('reads the assignment row and its open migration by primary key', async () => {
const database = await openInMemoryRelayDatabase()
const store = new RelayAssignmentStore(database, () => 100)
await store.reconcileCells([
{ id: 'cell-a', url: 'https://cell-a.example.test', capacityRequests: 10 },
{ id: 'cell-b', url: 'https://cell-b.example.test', capacityRequests: 10 }
])
const identity = { userId: 'user-a', relayHostId: HOST }
expect(await store.hostWhereabouts(identity)).toBeNull()
const assigned = await store.assign(identity)
expect(await store.hostWhereabouts(identity)).toEqual({
cellId: assigned.cellId,
assignmentEpoch: assigned.assignmentEpoch,
leaseExpiresAt: assigned.leaseExpiresAt,
openMigration: null
})
await database.query(
`INSERT INTO relay_assignment_migrations
(user_id, relay_host_id, source_cell_id, target_cell_id, previous_epoch, assignment_epoch,
source_request_units, target_reserved_units, expires_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
['user-a', HOST, 'cell-a', 'cell-b', 1, 2, 1, 1, 900, 100, 100]
)
expect((await store.hostWhereabouts(identity))?.openMigration).toEqual({
sourceCellId: 'cell-a',
targetCellId: 'cell-b',
previousEpoch: 1,
assignmentEpoch: 2,
expiresAt: 900,
targetRegisteredAt: null
})
})
})
function config(role: RelayConfig['role'] = 'director'): RelayConfig {
return {
port: 8080,
publicUrl: 'https://relay.example.test',
cellUrl: 'https://relay.example.test',
authIssuer: 'https://auth.example.test',
authAudience: 'orca-relay',
jwksUrl: 'https://auth.example.test/jwks',
assignmentSigningKey: new TextEncoder().encode('assignment-key-with-at-least-32-bytes'),
role,
cellId: role === 'cell' ? 'cell-a' : 'director',
cells: [],
adminAudience: 'https://relay.example.test/v1/admin/drain',
deployServiceAccount: 'deploy@example.test',
runtimeServiceAccount: 'runtime@example.test',
adminJwksUrl: 'https://auth.example.test/jwks',
databasePoolMax: 3,
publicAssignmentsEnabled: true,
publicAssignmentConcurrency: 2,
publicAssignmentQueueMax: 128,
publicAssignmentWaitMs: 4_000,
publicResolveConcurrency: 1,
publicResolveWaitMs: 5_000,
publicAssignmentRetryAfterSeconds: 5,
dataDir: './data'
}
}
@@ -300,7 +300,7 @@ function inlineRows(columns: ReadonlyArray<readonly [string, string]>, rows: num
}
function hostKey(row: SqlRow): string {
return `${String(row.user_id)}${String(row.relay_host_id)}`
return `${String(row.user_id)}\u0000${String(row.relay_host_id)}`
}
function idleRehomeCandidate(
+33 -3
View File
@@ -6,6 +6,14 @@ import { readRegionCorrectionOutcomes } from './region-correction-outcomes.js'
import { RelayAssignmentStore } from './assignment-store.js'
import { loadRelayConfig } from './config.js'
import { startCellHeartbeat } from './cell-heartbeat-client.js'
import {
CELL_FLAG_DEFAULTS,
CELL_FLAGS_APPLIED_EVENT,
type CellFlags,
cellFlagObjectName,
cellFlagParser
} from './cell-flags.js'
import { startControlFlagChannel } from './relay-control-flag-channel.js'
import {
reconcileCellAdmissionAtStartup,
roleOwnsAssignmentMaintenance
@@ -21,6 +29,7 @@ import { runRelayBackgroundOperation } from './relay-background-operation.js'
import { readPostgresLockWaitSample } from './postgres-lock-wait-sample.js'
import { jitteredSweepIntervalMs } from './relay-sweep-schedule.js'
import { observedRelayRequests } from './relay-observability.js'
import { handleRelayUnhandledRejection } from './relay-database-rejection-fence.js'
import { startRegionalRehomeWorker } from './regional-rehome-worker.js'
import { createRelayServer } from './relay-server.js'
import {
@@ -28,15 +37,33 @@ import {
readRegisteredMigrationInventory
} from './registered-migration-inventory.js'
// Both read the cell's switch file once it exists; until then, the boot values.
let cellFlagsApplied: (() => CellFlags) | null = null
// Before anything can start a database promise.
process.on('unhandledRejection', (reason) =>
handleRelayUnhandledRejection(reason, cellFlagsApplied?.().rejectionFence ?? true)
)
const config = loadRelayConfig()
const database = await openRelayDatabaseAtBoot({
databaseUrl: config.databaseUrl,
dataDir: config.dataDir,
poolMax: config.databasePoolMax,
applicationName: `orca-relay/${config.role}/${config.cellId}`,
appliesPostgresSchema: config.role !== 'cell'
appliesPostgresSchema: config.role !== 'cell',
readTimeoutMarginOverrideMs: () => cellFlagsApplied?.().readTimeoutMarginMs
})
await reconcileCellAdmissionAtStartup(config, new RelayAssignmentStore(database))
// Never awaited: the cell listens on defaults (all off) until the first read lands.
const cellFlagChannel =
config.role === 'cell'
? startControlFlagChannel({
objectName: cellFlagObjectName(config.cellId),
defaults: CELL_FLAG_DEFAULTS,
parse: cellFlagParser(config.cellId),
appliedEvent: CELL_FLAGS_APPLIED_EVENT
})
: null
cellFlagsApplied = cellFlagChannel ? () => cellFlagChannel.applied().flags : null
const {
server,
sessions,
@@ -46,8 +73,9 @@ const {
runtimeCounts,
connectionSnapshot,
ready,
cellIncarnation
} = createRelayServer(config, database)
cellIncarnation,
shadowSeatPoller
} = createRelayServer(config, database, { cellFlags: cellFlagChannel?.applied })
// Same owner as the assignment sweep: the cleanup only expires credentials that every reader
// already re-checks at read time, so running it in all 23 cells multiplied one table scan by 23
// without changing any answer.
@@ -158,6 +186,8 @@ const shutdown = (): void => {
observability.stop()
heartbeat?.stop()
regionalRehomeWorker?.stop()
shadowSeatPoller?.stop()
cellFlagChannel?.stop()
sessions.drain(0)
server.close(() => void database.close().catch(() => undefined))
}
@@ -0,0 +1,253 @@
import { describe, expect, it } from 'vitest'
import {
PLACEMENT_INTAKE_FLOOR,
PLACEMENT_INTAKE_SHARE,
PLACEMENT_INTAKE_WINDOW_MS,
PlacementLoadBand
} from './placement-load-band.js'
import { RelayAssignmentStore } from './assignment-store.js'
import { openInMemoryRelayDatabase } from './database.js'
function seededRandom(seed: number): () => number {
let state = seed >>> 0
return () => {
state = (state + 0x6d2b79f5) >>> 0
let value = Math.imul(state ^ (state >>> 15), 1 | state)
value = (value + Math.imul(value ^ (value >>> 7), 61 | value)) ^ value
return ((value ^ (value >>> 14)) >>> 0) / 4_294_967_296
}
}
type SimulatedCell = { cellId: string; reserved: number; capacity: number }
const quiet = () => undefined
// Each pick goes to one of the directors at random, as the load balancer spreads requests.
function simulate(
directors: PlacementLoadBand[],
cells: SimulatedCell[],
picks: number,
at: (index: number) => number,
route: () => number = seededRandom(99)
) {
const counts = new Map(cells.map((cell) => [cell.cellId, 0]))
for (let index = 0; index < picks; index += 1) {
const ranked = cells
.filter((cell) => cell.reserved < cell.capacity)
.map((cell) => ({ cellId: cell.cellId, loadRatio: cell.reserved / cell.capacity, cell }))
.sort(
(left, right) =>
left.loadRatio - right.loadRatio || left.cellId.localeCompare(right.cellId)
)
const director = directors[Math.floor(route() * directors.length)]
const picked = director?.pick(ranked, at(index))
if (!picked) throw new Error('no candidate')
picked.cell.reserved += 1
counts.set(picked.cellId, (counts.get(picked.cellId) ?? 0) + 1)
}
return counts
}
// c27's drain on 10-08: 2,770 hosts onto one empty Asia cell (2,805 free)
// and four fuller ones (2,311 free between them).
function c27DrainCells(): SimulatedCell[] {
return [
{ cellId: 'cell-empty', reserved: 0, capacity: 4000 },
...['cell-b', 'cell-c', 'cell-d', 'cell-e'].map((cellId, index) => ({
cellId,
reserved: 4000 - 577 - (index === 0 ? 3 : 0),
capacity: 4000
}))
]
}
function drainShare(paceMs: number, seed: number) {
const cells = c27DrainCells()
const directors = Array.from(
{ length: 5 },
(_, index) => new PlacementLoadBand(seededRandom(seed + index), quiet)
)
const hosts = 2770
const counts = simulate(directors, cells, hosts, (index) => (index * paceMs) / hosts)
return { counts, cells, share: (counts.get('cell-empty') ?? 0) / hosts }
}
describe('PlacementLoadBand', () => {
it('spreads a drain over an empty cell and four fuller cells within one window', () => {
const cells: SimulatedCell[] = [
{ cellId: 'cell-empty', reserved: 0, capacity: 6000 },
...['cell-b', 'cell-c', 'cell-d', 'cell-e'].map((cellId) => ({
cellId,
reserved: 2100,
capacity: 6000
}))
]
const director = new PlacementLoadBand(seededRandom(7), quiet)
const counts = simulate([director], cells, 2000, () => 1_000)
expect([...counts.values()].reduce((sum, count) => sum + count, 0)).toBe(2000)
expect(counts.get('cell-empty')).toBeLessThanOrEqual(PLACEMENT_INTAKE_SHARE * 2000 + 1)
for (const cellId of ['cell-b', 'cell-c', 'cell-d', 'cell-e']) {
expect(counts.get(cellId)).toBeGreaterThan(200)
}
for (const cell of cells) expect(cell.reserved).toBeLessThanOrEqual(cell.capacity)
})
it.each([
['1,200 s', 1_200_000],
['300 s', 300_000]
])('keeps the empty cell near the share across five directors at the %s pace', (_, paceMs) => {
for (const seed of [1, 2, 3]) {
const { counts, cells, share } = drainShare(paceMs, seed * 10)
expect([...counts.values()].reduce((sum, count) => sum + count, 0)).toBe(2770)
expect(share).toBeGreaterThan(0.3)
expect(share).toBeLessThan(0.5)
for (const cell of cells) expect(cell.reserved).toBeLessThanOrEqual(cell.capacity)
}
})
it('gives the empty cell about 60% when only one other cell is eligible', () => {
const cells: SimulatedCell[] = [
{ cellId: 'cell-empty', reserved: 0, capacity: 6000 },
{ cellId: 'cell-b', reserved: 2100, capacity: 6000 }
]
const counts = simulate([new PlacementLoadBand(seededRandom(4), quiet)], cells, 1000, () => 1)
const share = (counts.get('cell-empty') ?? 0) / 1000
expect(share).toBeGreaterThan(0.55)
expect(share).toBeLessThan(0.65)
})
it('logs one summary a minute with skips, fallbacks and band size', () => {
const lines: Record<string, unknown>[] = []
const band = new PlacementLoadBand(seededRandom(6), (line) => lines.push(JSON.parse(line)))
const ranked = [
{ cellId: 'cell-a', loadRatio: 0 },
{ cellId: 'cell-b', loadRatio: 0.5 }
]
for (let second = 0; second <= 60; second += 1) band.pick(ranked, second * 1_000)
expect(lines).toHaveLength(1)
expect(lines[0]).toMatchObject({
event: 'orca_relay_placement_load_band',
windowMs: 60_000,
picks: 61,
bandSizeMax: 1
})
expect(lines[0]?.budgetSkips).toBeGreaterThan(0)
expect(lines[0]?.fallbacks).toBeGreaterThan(0)
})
it('never places past capacity while the fuller cells fill', () => {
const cells: SimulatedCell[] = [
{ cellId: 'cell-empty', reserved: 0, capacity: 100 },
...['cell-b', 'cell-c'].map((cellId) => ({ cellId, reserved: 90, capacity: 100 }))
]
const director = new PlacementLoadBand(seededRandom(11), quiet)
const counts = simulate([director], cells, 120, () => 1_000)
expect(cells.map((cell) => cell.reserved)).toEqual([100, 100, 100])
expect(counts.get('cell-empty')).toBe(100)
})
it('falls back to the least-loaded cell when every candidate is over budget', () => {
const band = new PlacementLoadBand(seededRandom(3), quiet)
const only = [{ cellId: 'cell-a', loadRatio: 0.5 }]
for (let index = 0; index < 50; index += 1) {
expect(band.pick(only, 1_000)?.cellId).toBe('cell-a')
}
expect(band.pick([], 1_000)).toBeUndefined()
})
it('keeps quiet placement on the least-loaded cell outside the band', () => {
let now = 0
const band = new PlacementLoadBand(seededRandom(5), quiet)
const ranked = [
{ cellId: 'cell-a', loadRatio: 0.1 },
{ cellId: 'cell-b', loadRatio: 0.3 }
]
for (let index = 0; index < 20; index += 1) {
now += PLACEMENT_INTAKE_WINDOW_MS / PLACEMENT_INTAKE_FLOOR
expect(band.pick(ranked, now)?.cellId).toBe('cell-a')
}
})
it('picks at random among cells inside the band', () => {
const band = new PlacementLoadBand(seededRandom(9), quiet)
const ranked = [
{ cellId: 'cell-a', loadRatio: 0.3 },
{ cellId: 'cell-b', loadRatio: 0.32 },
{ cellId: 'cell-c', loadRatio: 0.4 }
]
const picked = new Set<string>()
let now = 0
for (let index = 0; index < 40; index += 1) {
now += PLACEMENT_INTAKE_WINDOW_MS
picked.add(band.pick(ranked, now)?.cellId ?? '')
}
expect([...picked].sort()).toEqual(['cell-a', 'cell-b'])
})
})
describe('leastLoadedCell with the load band', () => {
async function setupRegions() {
const database = await openInMemoryRelayDatabase()
const store = new RelayAssignmentStore(database, () => 1_000, {
placementLoadBand: new PlacementLoadBand(seededRandom(13), quiet)
})
const asia = ['asia-a', 'asia-b', 'asia-c', 'asia-d', 'asia-e']
await store.reconcileCells([
...asia.map((id) => ({
id,
url: `https://${id}.example.com`,
capacityRequests: 100,
region: 'asia-east2' as const
})),
{
id: 'us-a',
url: 'https://us-a.example.com',
capacityRequests: 1000,
region: 'us-central1' as const
}
])
await database.query(
`UPDATE relay_cells SET observed_requests = 35 WHERE cell_id IN (?, ?, ?, ?)`,
asia.slice(1)
)
return { database, store }
}
async function placements(store: RelayAssignmentStore, count: number) {
const counts = new Map<string, number>()
for (let index = 0; index < count; index += 1) {
const assignment = await store.assign(
{ userId: `user-${index}`, relayHostId: `host${String(index).padStart(12, '0')}` },
'asia-east2'
)
counts.set(assignment.cellId, (counts.get(assignment.cellId) ?? 0) + 1)
}
return counts
}
it('spreads within the preferred region and keeps an empty US cell out', async () => {
const { store } = await setupRegions()
const counts = await placements(store, 200)
expect(counts.get('us-a')).toBeUndefined()
expect(counts.get('asia-a')).toBeLessThanOrEqual(PLACEMENT_INTAKE_SHARE * 200 + 1)
const used = [...counts.values()].filter((count) => count >= 20)
expect(used.length).toBeGreaterThanOrEqual(3)
})
it('fills the preferred region to its hard cap before spilling, never past it', async () => {
const { database, store } = await setupRegions()
const counts = await placements(store, 520)
const rows = await database.query(
`SELECT reserved_requests, capacity_requests FROM relay_cells`
)
// The hard cap is reserved < capacity: five Asia cells hold 500, the rest spill to the US.
expect(counts.get('us-a')).toBe(20)
for (const row of rows) {
expect(Number(row.reserved_requests)).toBeLessThanOrEqual(Number(row.capacity_requests))
}
})
})
+120
View File
@@ -0,0 +1,120 @@
// Spreads placement over cells near the minimum load, so a freshly rolled
// (empty) cell does not take a whole drain and serialize every follow-up on
// its one relay_cells row. It only orders candidates: a candidate is never
// refused, and the pool's least-loaded cell is the fallback.
// Cells within this share of capacity of the least-loaded one are equals.
export const PLACEMENT_LOAD_BAND = 0.05
// A minute, so a director at the 1,200 s drain pace (~0.5 picks/s) still
// holds enough picks for the share, not the floor, to decide.
export const PLACEMENT_INTAKE_WINDOW_MS = 60_000
// A cell over this share of its pool's recent placements yields to the rest.
export const PLACEMENT_INTAKE_SHARE = 0.4
// Below this many recent picks a cell is never skipped, so quiet traffic
// still goes to the least-loaded cell.
export const PLACEMENT_INTAKE_FLOOR = 2
export const PLACEMENT_LOAD_BAND_SUMMARY_MS = 60_000
export type LoadBandCandidate = { cellId: string; loadRatio: number }
type LoadBandSummary = {
picks: number
// Picks where at least one candidate was over budget and skipped.
budgetSkips: number
// Picks where every candidate was over budget: the least-loaded cell took it.
fallbacks: number
bandSizeTotal: number
bandSizeMax: number
picksByCell: Record<string, number>
}
function emptySummary(): LoadBandSummary {
return {
picks: 0,
budgetSkips: 0,
fallbacks: 0,
bandSizeTotal: 0,
bandSizeMax: 0,
picksByCell: {}
}
}
export class PlacementLoadBand {
// Director-instance-local and in memory: it shapes order, never correctness.
private readonly intake = new Map<string, number[]>()
private summary = emptySummary()
private summaryStartedAt: number | undefined
constructor(
private readonly random: () => number = Math.random,
private readonly log: (line: string) => void = (line) => console.info(line)
) {}
// `sorted` is ascending by load ratio. Records the pick as intake; a pick
// whose transaction retries counts again, which only spreads harder.
pick<T extends LoadBandCandidate>(sorted: readonly T[], now: number): T | undefined {
const least = sorted[0]
if (!least) return undefined
const recent = new Map(
sorted.map((candidate) => [candidate.cellId, this.recentIntake(candidate.cellId, now)])
)
let poolIntake = 0
for (const count of recent.values()) poolIntake += count
const budget = Math.max(PLACEMENT_INTAKE_FLOOR, PLACEMENT_INTAKE_SHARE * poolIntake)
const withinBudget = sorted.filter((candidate) => (recent.get(candidate.cellId) ?? 0) < budget)
const pool = withinBudget.length > 0 ? withinBudget : [least]
const ceiling = pool[0]!.loadRatio + PLACEMENT_LOAD_BAND
const band = pool.filter((candidate) => candidate.loadRatio <= ceiling)
const picked = band[Math.min(band.length - 1, Math.floor(this.random() * band.length))]!
this.record(picked.cellId, now)
this.observe(now, {
cellId: picked.cellId,
skipped: withinBudget.length < sorted.length,
fallback: withinBudget.length === 0,
bandSize: band.length
})
return picked
}
// One line a minute tells a spread drain from a floor- or fallback-dominated one.
private observe(
now: number,
pick: { cellId: string; skipped: boolean; fallback: boolean; bandSize: number }
): void {
this.summaryStartedAt ??= now
const summary = this.summary
summary.picks += 1
if (pick.skipped) summary.budgetSkips += 1
if (pick.fallback) summary.fallbacks += 1
summary.bandSizeTotal += pick.bandSize
summary.bandSizeMax = Math.max(summary.bandSizeMax, pick.bandSize)
summary.picksByCell[pick.cellId] = (summary.picksByCell[pick.cellId] ?? 0) + 1
if (now - this.summaryStartedAt < PLACEMENT_LOAD_BAND_SUMMARY_MS) return
this.log(
JSON.stringify({
event: 'orca_relay_placement_load_band',
windowMs: now - this.summaryStartedAt,
...summary
})
)
this.summary = emptySummary()
this.summaryStartedAt = now
}
private recentIntake(cellId: string, now: number): number {
const times = this.intake.get(cellId)
if (!times) return 0
const cutoff = now - PLACEMENT_INTAKE_WINDOW_MS
let expired = 0
while (expired < times.length && times[expired]! <= cutoff) expired += 1
if (expired > 0) times.splice(0, expired)
if (times.length === 0) this.intake.delete(cellId)
return times.length
}
private record(cellId: string, now: number): void {
const times = this.intake.get(cellId)
if (times) times.push(now)
else this.intake.set(cellId, [now])
}
}
@@ -0,0 +1,187 @@
import { createServer, connect, type Server, type Socket } from 'node:net'
import { afterEach, describe, expect, it } from 'vitest'
import {
isRelayDatabaseTransientError,
openRelayDatabase,
POSTGRES_READ_TIMEOUT_MARGIN_MS,
type RelayDatabase
} from './database.js'
const databaseUrl = process.env.ORCA_RELAY_TEST_POSTGRES_URL
const describePostgres = databaseUrl ? describe : describe.skip
const STATEMENT_TIMEOUT_MS = 500
// A short margin keeps the blackhole case fast; production's is 10 s.
const READ_TIMEOUT_MARGIN_MS = 2_000
const READ_TIMEOUT_MS = STATEMENT_TIMEOUT_MS + READ_TIMEOUT_MARGIN_MS
const PROBE_TABLE = 'relay_lost_reply_probe'
// Forwards each connection to PostgreSQL. Once a connection sends a statement naming the
// trigger, the server's replies on that connection are dropped (a reply lost in the network),
// or, with `delayMs`, held that long and then delivered (a whole-VM database stall).
async function blackholeProxy(target: URL, trigger: string, delayMs?: number) {
const sockets = new Set<Socket>()
const afterTrigger: Buffer[] = []
const server: Server = createServer((client) => {
const upstream = connect(Number(target.port || 5432), target.hostname)
sockets.add(client)
sockets.add(upstream)
let swallowing = false
client.on('data', (chunk: Buffer) => {
if (swallowing) afterTrigger.push(chunk)
else if (chunk.includes(trigger)) swallowing = true
upstream.write(chunk)
})
upstream.on('data', (chunk: Buffer) => {
if (!swallowing) client.write(chunk)
else if (delayMs !== undefined) setTimeout(() => client.write(chunk), delayMs)
})
for (const socket of [client, upstream]) {
socket.on('error', () => undefined)
socket.on('close', () => {
client.destroy()
upstream.destroy()
})
}
})
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
const address = server.address()
if (typeof address !== 'object' || address === null) throw new Error('proxy not listening')
const url = new URL(target)
url.hostname = '127.0.0.1'
url.port = String(address.port)
return {
url: url.toString(),
sentAfterTrigger: () => Buffer.concat(afterTrigger).toString('latin1'),
close: async () => {
for (const socket of sockets) socket.destroy()
await new Promise<void>((resolve) => server.close(() => resolve()))
}
}
}
describePostgres('a lost PostgreSQL reply against a real server', () => {
const cleanups: Array<() => Promise<void>> = []
afterEach(async () => {
for (const cleanup of cleanups.splice(0).reverse()) await cleanup()
})
async function open(
url: string,
options: {
readTimeoutMarginMs?: number
poolMax?: number
readTimeoutMarginOverrideMs?: () => number | undefined
} = {}
): Promise<RelayDatabase> {
const database = await openRelayDatabase({
databaseUrl: url,
dataDir: '',
appliesPostgresSchema: false,
statementTimeoutMs: STATEMENT_TIMEOUT_MS,
readTimeoutMarginMs: options.readTimeoutMarginMs ?? READ_TIMEOUT_MARGIN_MS,
poolMax: options.poolMax,
readTimeoutMarginOverrideMs: options.readTimeoutMarginOverrideMs
})
cleanups.push(async () => await database.close())
return database
}
it('fails within the read timeout, sends nothing after the lost statement, and recovers', async () => {
const direct = await open(databaseUrl!)
await direct.query(`CREATE TABLE IF NOT EXISTS ${PROBE_TABLE} (id TEXT PRIMARY KEY)`)
await direct.query(`INSERT INTO ${PROBE_TABLE} (id) VALUES ('c1') ON CONFLICT DO NOTHING`)
cleanups.push(async () => {
await direct.query(`DROP TABLE IF EXISTS ${PROBE_TABLE}`)
})
const proxy = await blackholeProxy(new URL(databaseUrl!), `FROM ${PROBE_TABLE}`)
cleanups.push(proxy.close)
const database = await open(proxy.url)
const startedAt = performance.now()
const failure = await database
.transaction(
async (transaction) =>
await transaction.queryLocked(`SELECT id FROM ${PROBE_TABLE} WHERE id = ?`, ['c1'], {
lockTimeoutMs: 200
})
)
.catch((error: unknown) => error)
const elapsedMs = performance.now() - startedAt
expect(failure).toBeInstanceOf(Error)
expect(isRelayDatabaseTransientError(failure)).toBe(true)
expect(elapsedMs).toBeLessThan(READ_TIMEOUT_MS + 1_000)
// pg's Terminate ('X') may follow as the client is destroyed; no statement may.
const after = proxy.sentAfterTrigger()
for (const statement of ['lock_timeout', 'ROLLBACK', 'COMMIT', 'SELECT']) {
expect(after).not.toContain(statement)
}
// The next checkout is a fresh connection, so it is not stuck behind the lost reply.
await expect(database.query('SELECT 1 AS one')).resolves.toEqual([{ one: 1 }])
// The row lock went with the destroyed session.
await expect(
direct.transaction(
async (transaction) =>
await transaction.queryLocked(`SELECT id FROM ${PROBE_TABLE} WHERE id = ?`, ['c1'], {
failIfUnavailable: true
})
)
).resolves.toEqual([{ id: 'c1' }])
}, 20_000)
// The per-cell switch moves the margin per query, with no restart and no new pool.
it('applies the switch file margin per query, over the pool margin', async () => {
const direct = await open(databaseUrl!)
await direct.query(`CREATE TABLE IF NOT EXISTS ${PROBE_TABLE} (id TEXT PRIMARY KEY)`)
cleanups.push(async () => {
await direct.query(`DROP TABLE IF EXISTS ${PROBE_TABLE}`)
})
const proxy = await blackholeProxy(new URL(databaseUrl!), `FROM ${PROBE_TABLE}`)
cleanups.push(proxy.close)
let override: number | undefined = 1_000
// The pool's own margin would hold the lost reply for 30 s.
const database = await open(proxy.url, {
readTimeoutMarginMs: 30_000,
readTimeoutMarginOverrideMs: () => override
})
await expect(database.query('SELECT 1 AS one')).resolves.toEqual([{ one: 1 }])
const startedAt = performance.now()
const failure = await database
.query(`SELECT id FROM ${PROBE_TABLE}`)
.catch((error: unknown) => error)
expect(isRelayDatabaseTransientError(failure)).toBe(true)
expect(performance.now() - startedAt).toBeLessThan(STATEMENT_TIMEOUT_MS + 1_000 + 1_000)
override = undefined
await expect(database.query('SELECT 1 AS one')).resolves.toEqual([{ one: 1 }])
}, 20_000)
// A reply the server sent after statement_timeout, held up by a whole-VM stall, is still a
// reply: the default margin must outlast the routine 5-7 s stalls.
it('keeps a reply delayed 6.5 s by a stall, on the same connection', async () => {
const proxy = await blackholeProxy(new URL(databaseUrl!), 'stalled_reply_probe', 6_500)
cleanups.push(proxy.close)
const database = await open(proxy.url, {
readTimeoutMarginMs: POSTGRES_READ_TIMEOUT_MARGIN_MS,
poolMax: 1
})
const pid = async () => (await database.query('SELECT pg_backend_pid() AS pid'))[0]?.pid
const before = await pid()
const startedAt = performance.now()
// Autocommit: inside a transaction the proxy's hold would trip the server's own 5 s
// idle-in-transaction limit, which a real VM stall pauses along with everything else.
await expect(database.query("SELECT 'stalled_reply_probe' AS probe")).resolves.toEqual([
{ probe: 'stalled_reply_probe' }
])
expect(performance.now() - startedAt).toBeGreaterThan(6_000)
// Not destroyed: the one pooled connection is still the same backend.
expect(await pid()).toBe(before)
}, 30_000)
it('runs request sessions with the server bounding idle backends at 30 s', async () => {
const database = await open(databaseUrl!)
await expect(
database.query("SELECT current_setting('idle_session_timeout') AS value")
).resolves.toEqual([{ value: '30s' }])
})
})
@@ -0,0 +1,124 @@
import { EventEmitter } from 'node:events'
import { describe, expect, it, vi } from 'vitest'
import { isRelayDatabaseTransientError, PostgresDatabase } from './database.js'
import {
isFencedRelayDatabaseRejection,
POSTGRES_READ_TIMEOUT_MESSAGE
} from './relay-database-rejection-fence.js'
// pg's query_timeout firing: the statement's reply never arrived.
const readTimeout = () => new Error(POSTGRES_READ_TIMEOUT_MESSAGE)
class FakePoolClient extends EventEmitter {
readonly statements: string[] = []
readonly released: Array<Error | boolean | undefined> = []
constructor(private readonly timesOut: (sql: string) => boolean) {
super()
}
query = vi.fn(async (sql: string) => {
this.statements.push(sql)
if (this.timesOut(sql)) throw readTimeout()
return { rows: [{ one: 1 }], rowCount: 1 }
})
release = (error?: Error | boolean): void => {
this.released.push(error)
}
}
function databaseOver(timesOut: (sql: string) => boolean) {
const clients: FakePoolClient[] = []
const database = new PostgresDatabase({
totalCount: 1,
idleCount: 0,
waitingCount: 0,
connect: async () => {
const client = new FakePoolClient(timesOut)
clients.push(client)
return client
}
} as never)
return { database, clients }
}
describe('a lost PostgreSQL reply', () => {
it('ends a bounded locked statement with nothing else sent on its connection', async () => {
const { database, clients } = databaseOver((sql) => sql.includes('FOR UPDATE'))
const failure = await database
.transaction(
async (transaction) =>
await transaction.queryLocked('SELECT id FROM cells WHERE id = ?', ['c1'], {
lockTimeoutMs: 200
})
)
.catch((error: unknown) => error)
expect(failure).toEqual(readTimeout())
// Not retried, no lock_timeout restore, no ROLLBACK, no COMMIT behind the dead statement.
expect(clients).toHaveLength(1)
expect(clients[0]?.statements).toEqual([
'BEGIN',
"SET LOCAL lock_timeout = '200ms'",
'SELECT id FROM cells WHERE id = $1 FOR UPDATE'
])
// Released with the error, so pg-pool destroys it instead of reusing it.
expect(clients[0]?.released).toEqual([failure])
// Answered 503 like a dropped connection, and survivable if nothing awaited it.
expect(isRelayDatabaseTransientError(failure)).toBe(true)
expect(isFencedRelayDatabaseRejection(failure)).toBe(true)
})
it('never commits after an operation that caught the timeout and carried on', async () => {
const { database, clients } = databaseOver((sql) => sql === 'UPDATE cells SET n = n + 1')
const failure = await database
.transaction(async (transaction) => {
await transaction.query('UPDATE cells SET n = n + 1').catch(() => undefined)
// Every later statement on the poisoned transaction fails at once, unsent.
await expect(transaction.query('SELECT 1')).rejects.toEqual(readTimeout())
return 'carried on'
})
.catch((error: unknown) => error)
expect(failure).toEqual(readTimeout())
expect(clients[0]?.statements).toEqual(['BEGIN', 'UPDATE cells SET n = n + 1'])
expect(clients[0]?.released).toEqual([failure])
})
it('treats a lost COMMIT reply as an unknown outcome: no ROLLBACK, no retry', async () => {
const { database, clients } = databaseOver((sql) => sql === 'COMMIT')
await expect(
database.transaction(async (transaction) => await transaction.query('SELECT 1'))
).rejects.toEqual(readTimeout())
expect(clients).toHaveLength(1)
expect(clients[0]?.statements).toEqual(['BEGIN', 'SELECT 1', 'COMMIT'])
expect(clients[0]?.released[0]).toEqual(readTimeout())
})
it('destroys the client of a timed-out autocommit statement and serves the next one', async () => {
let first = true
const { database, clients } = databaseOver(() => {
const timesOut = first
first = false
return timesOut
})
vi.spyOn(console, 'warn').mockImplementation(() => {})
await expect(database.query('SELECT 1')).rejects.toEqual(readTimeout())
await expect(database.query('SELECT 1')).resolves.toEqual([{ one: 1 }])
expect(clients.map((client) => client.released)).toEqual([[readTimeout()], [undefined]])
})
it('still rolls back and retries an ordinary server error', async () => {
let failures = 1
const { database, clients } = databaseOver(() => false)
const result = await database.transaction(async (transaction) => {
if (failures-- > 0) throw Object.assign(new Error('deadlock detected'), { code: '40P01' })
return await transaction.query('SELECT 1')
})
expect(result).toEqual([{ one: 1 }])
expect(clients.map((client) => client.statements)).toEqual([
['BEGIN', 'ROLLBACK'],
['BEGIN', 'SELECT 1', 'COMMIT']
])
expect(clients.map((client) => client.released)).toEqual([[undefined], [undefined]])
})
})
+12 -3
View File
@@ -21,6 +21,14 @@ const POOL_CONNECT_TIMEOUT_MESSAGES = [
// pg raises this whenever a socket ends early, during the handshake and mid
// statement alike, so only the acquire boundary can tell the two apart.
const CONNECTION_TERMINATED_MESSAGE = 'Connection terminated unexpectedly'
// The socket to Postgres (or its proxy) failed before a client existed, e.g. a Cloud SQL restart.
const ACQUIRE_SOCKET_ERRNOS = new Set([
'EPIPE',
'ECONNRESET',
'ECONNREFUSED',
'ETIMEDOUT',
'EHOSTUNREACH'
])
// Membership is tracked beside the error rather than on it: an error object may
// be frozen, and a mutated one would leak the marker into logs.
@@ -34,7 +42,7 @@ function errorCode(error: unknown): string {
return String((error as { code?: unknown } | null)?.code)
}
function isPostgresPoolAcquireFailure(error: unknown): boolean {
export function isPostgresPoolAcquireFailure(error: unknown): boolean {
return typeof error === 'object' && error !== null && poolAcquireFailures.has(error)
}
@@ -49,9 +57,10 @@ export function isPostgresPoolConnectTimeout(error: unknown): boolean {
// outcome is unknown is not safe.
export function isPostgresPoolConnectFailure(error: unknown): boolean {
if (isPostgresPoolConnectTimeout(error)) return true
if (!isPostgresPoolAcquireFailure(error)) return false
return (
errorMessage(error).includes(CONNECTION_TERMINATED_MESSAGE) &&
isPostgresPoolAcquireFailure(error)
errorMessage(error).includes(CONNECTION_TERMINATED_MESSAGE) ||
ACQUIRE_SOCKET_ERRNOS.has(errorCode(error))
)
}
@@ -72,12 +72,12 @@ describe('PostgreSQL query failure diagnostics', () => {
expect(JSON.stringify(vi.mocked(console.warn).mock.calls)).not.toContain('private')
})
// ECONNRESET carries no SQLSTATE the routes retry on, and it arrives after the
// statement went out, so it stays a hard failure. The pair pins that boundary.
// A reset from the database layer answers 503 like 08006 (staging B1); transaction() still
// never retries it, since the statement went out.
it.each([
['57014', true],
['55P03', true],
['ECONNRESET', false]
['ECONNRESET', true]
] as const)(
'identifies execute failure %s and releases its client',
async (code, transient) => {
@@ -169,4 +169,22 @@ describe('RelayConnectionLedger', () => {
enforcedConnectionUnits: 0
})
})
it('books units only below the placement ceiling and releases each once', () => {
const ledger = new RelayConnectionLedger(6, 2)
const first = ledger.tryReserveBooking(2)!
expect(ledger.tryReserveBooking(2)).not.toBeNull()
expect(ledger.tryReserveBooking(2)).toBeNull()
// A host control may still rise to the socket ceiling past the bookings.
expect(ledger.tryReserveControl(false)).not.toBeNull()
first.release()
first.release()
expect(ledger.counts().reservedConnectionUnits).toBe(1)
// Never above the socket ceiling, whatever the caller's ceiling says.
expect(ledger.canReserveBooking(100)).toBe(true)
ledger.tryReserveControl(false)
ledger.tryReserveControl(false)
expect(ledger.canReserveBooking(100)).toBe(false)
})
})
@@ -146,6 +146,27 @@ export class RelayConnectionLedger {
return this.reserveUpgrade(this.normalAdmissionLimit)
}
// A step-5 booking holds one reserved unit, but only below the placement ceiling the
// director would use, so phones keep the headroom between it and the socket ceiling.
tryReserveBooking(limit: number): { release(): void } | null {
if (!this.canReserveBooking(limit)) return null
this.advanceWatermark()
this.reservedConnectionUnits++
let released = false
return {
release: () => {
if (released) return
released = true
this.advanceWatermark()
this.reservedConnectionUnits--
}
}
}
canReserveBooking(limit: number): boolean {
return this.enforcedConnectionUnits + 1 <= Math.min(limit, this.normalAdmissionLimit)
}
counts(): RelayConnectionLedgerCounts {
return {
physicalConnections: this.physicalConnections,
@@ -0,0 +1,289 @@
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
import {
CELL_FLAG_DEFAULTS,
CELL_FLAGS_APPLIED_EVENT,
cellFlagObjectName,
cellFlagParser
} from './cell-flags.js'
import { CONTROL_FLAG_POLL_MS, startControlFlagChannel } from './relay-control-flag-channel.js'
const CELL_ID = 'production-gce-c7'
const ACCESS_TOKEN = 'ya29.secret-access-token'
type StoredObject =
| { kind: 'object'; generation: number; body: string }
| { kind: 'status'; status: number }
| { kind: 'network-error' }
function fakeGoogle(initial: StoredObject = { kind: 'status', status: 404 }) {
const state = { object: initial, storageRequests: [] as URL[], authorizations: [] as string[] }
const fetchImpl: typeof fetch = async (target, init) => {
const url = new URL(String(target))
if (url.hostname === 'metadata.google.internal') {
if (url.pathname.endsWith('/project/project-id')) return new Response('onorca-cloud')
return Response.json({ access_token: ACCESS_TOKEN, expires_in: 3_600 })
}
state.storageRequests.push(url)
state.authorizations.push(new Headers(init?.headers).get('authorization') ?? '')
const object = state.object
if (object.kind === 'network-error') throw new TypeError('fetch failed')
if (object.kind === 'status') return new Response(null, { status: object.status })
if (url.searchParams.get('ifGenerationNotMatch') === String(object.generation)) {
return new Response(null, { status: 304 })
}
return new Response(object.body, {
headers: { 'x-goog-generation': String(object.generation) }
})
}
return { state, fetchImpl }
}
function cellObject(generation: number, flags: Record<string, unknown>, cellId = CELL_ID) {
return {
kind: 'object' as const,
generation,
body: JSON.stringify({ v: 1, cellId, flags })
}
}
function cellChannel(fetchImpl: typeof fetch, autoStart = false) {
return startControlFlagChannel({
objectName: cellFlagObjectName(CELL_ID),
defaults: CELL_FLAG_DEFAULTS,
parse: cellFlagParser(CELL_ID),
appliedEvent: CELL_FLAGS_APPLIED_EVENT,
fetch: fetchImpl,
random: () => 0.5,
autoStart
})
}
describe('control flag channel', () => {
let log: MockInstance<typeof console.log>
let warn: MockInstance<typeof console.warn>
beforeEach(() => {
log = vi.spyOn(console, 'log').mockImplementation(() => {})
warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
})
afterEach(() => {
log.mockRestore()
warn.mockRestore()
vi.useRealTimers()
})
const appliedLines = () =>
log.mock.calls
.map((call) => String(call[0]))
.filter((line) => line.includes(`"event":"${CELL_FLAGS_APPLIED_EVENT}"`))
.map((line): unknown => JSON.parse(line))
it('runs on defaults until a first read, then reads the cell object in the project bucket', async () => {
const google = fakeGoogle(cellObject(5, { readinessLocal: true }))
const channel = cellChannel(google.fetchImpl)
expect(channel.applied()).toEqual({ generation: 0, flags: CELL_FLAG_DEFAULTS })
await channel.poll()
expect(google.state.storageRequests[0]?.pathname).toBe(
`/storage/v1/b/onorca-cloud-relay-control/o/${encodeURIComponent(`cells/${CELL_ID}.json`)}`
)
expect(google.state.authorizations[0]).toBe(`Bearer ${ACCESS_TOKEN}`)
expect(channel.applied()).toEqual({
generation: 5,
flags: { ...CELL_FLAG_DEFAULTS, readinessLocal: true }
})
})
// How a flip is observed: one applied line per generation, and the seat feed's flagsApplied.
it('flips a flag on and back off, logging each applied generation once', async () => {
const google = fakeGoogle(cellObject(5, { readinessLocal: true, ticketCheck: 'shadow' }))
const channel = cellChannel(google.fetchImpl)
await channel.poll()
await channel.poll()
expect(google.state.storageRequests[1]?.searchParams.get('ifGenerationNotMatch')).toBe('5')
google.state.object = cellObject(7, { readinessLocal: false, ticketCheck: 'off' })
await channel.poll()
expect(channel.applied()).toEqual({ generation: 7, flags: CELL_FLAG_DEFAULTS })
expect(appliedLines()).toEqual([
{
event: CELL_FLAGS_APPLIED_EVENT,
generation: 5,
flags: { ...CELL_FLAG_DEFAULTS, readinessLocal: true, ticketCheck: 'shadow' }
},
{ event: CELL_FLAGS_APPLIED_EVENT, generation: 7, flags: CELL_FLAG_DEFAULTS }
])
})
it('keeps the applied values through every kind of lost contact, logging once per cause', async () => {
const google = fakeGoogle(cellObject(5, { readinessLocal: true }))
const channel = cellChannel(google.fetchImpl)
await channel.poll()
const outages: StoredObject[] = [
{ kind: 'status', status: 503 },
{ kind: 'status', status: 503 },
{ kind: 'status', status: 404 },
{ kind: 'network-error' },
{ kind: 'object', generation: 6, body: '{not json' }
]
for (const outage of outages) {
google.state.object = outage
await channel.poll()
expect(channel.applied().flags.readinessLocal).toBe(true)
}
const failures = warn.mock.calls.map((call) => String(call[0]).match(/"failure":"([a-z-]+)"/)?.[1])
expect(failures).toEqual(['http', 'not-found', 'network', 'malformed'])
const output = [...log.mock.calls, ...warn.mock.calls].flat().map(String).join('\n')
expect(output).not.toContain(ACCESS_TOKEN)
})
it('applies the step-5 switches: admit mode, intake rate and enforced tickets', async () => {
const google = fakeGoogle(
cellObject(5, { admitMode: 'reserve', intakePerSec: 3.5, ticketCheck: 'enforce' })
)
const channel = cellChannel(google.fetchImpl)
await channel.poll()
expect(channel.applied().flags).toEqual({
...CELL_FLAG_DEFAULTS,
admitMode: 'reserve',
intakePerSec: 3.5,
ticketCheck: 'enforce'
})
})
it('applies the fence and read-timeout switches within their bounds', async () => {
const google = fakeGoogle(cellObject(5, { rejectionFence: false, readTimeoutMarginMs: 4_000 }))
const channel = cellChannel(google.fetchImpl)
await channel.poll()
expect(channel.applied()).toEqual({
generation: 5,
flags: { ...CELL_FLAG_DEFAULTS, rejectionFence: false, readTimeoutMarginMs: 4_000 }
})
})
it('ignores a generation below the applied one', async () => {
const google = fakeGoogle(cellObject(9, { readinessLocal: true }))
const channel = cellChannel(google.fetchImpl)
await channel.poll()
google.state.object = cellObject(8, { readinessLocal: false })
await channel.poll()
expect(channel.applied()).toEqual({
generation: 9,
flags: { ...CELL_FLAG_DEFAULTS, readinessLocal: true }
})
})
it('ignores unknown keys but voids the object on a bad known value or another cell', async () => {
const google = fakeGoogle(cellObject(5, { readinessLocal: true, placer: 'memory' }))
const channel = cellChannel(google.fetchImpl)
await channel.poll()
expect(channel.applied()).toEqual({
generation: 5,
flags: { ...CELL_FLAG_DEFAULTS, readinessLocal: true },
ignoredKeys: ['placer']
})
expect(appliedLines().at(-1)).toMatchObject({ ignoredKeys: ['placer'] })
for (const [generation, object] of [
[6, cellObject(6, { readinessLocal: false, ticketCheck: 'strict' })],
[7, cellObject(7, { readinessLocal: false, admitMode: 'memory' })],
[8, cellObject(8, { readinessLocal: false, intakePerSec: -1 })],
[9, cellObject(9, { readinessLocal: 'no' })],
[10, cellObject(10, { readTimeoutMarginMs: 500 })],
[11, cellObject(11, { readTimeoutMarginMs: 61_000 })],
[12, cellObject(12, { rejectionFence: 'off' })],
[13, cellObject(13, { readinessLocal: false }, 'production-gce-c8')]
] as const) {
google.state.object = object
await channel.poll()
expect(channel.applied().generation).toBe(5)
// A voided generation is not re-read until it changes.
await channel.poll()
expect(google.state.storageRequests.at(-1)?.searchParams.get('ifGenerationNotMatch')).toBe(
String(generation)
)
}
})
it('releases every reply it does not read, and stops reading past 64 KiB', async () => {
let cancelled = 0
const streamed = (body: string, status = 200, generation = 5) =>
new Response(
new ReadableStream<Uint8Array>({
pull(controller) {
controller.enqueue(new TextEncoder().encode(body))
},
cancel() {
cancelled += 1
}
}),
{ status, headers: { 'x-goog-generation': String(generation) } }
)
let next: () => Response = () => streamed('', 404)
const google = fakeGoogle()
const fetchImpl: typeof fetch = async (target, init) =>
new URL(String(target)).hostname === 'metadata.google.internal'
? await google.fetchImpl(target, init)
: next()
const channel = cellChannel(fetchImpl)
for (const status of [404, 503]) {
next = () => streamed('', status)
await channel.poll()
}
expect(cancelled).toBe(2)
// An endless body: the pull source never closes, so only the bound ends the read.
next = () => streamed('x'.repeat(16 * 1024), 200, 5)
await channel.poll()
expect(cancelled).toBe(3)
expect(channel.applied().generation).toBe(0)
next = () =>
new Response(JSON.stringify({ v: 1, cellId: CELL_ID, flags: { readinessLocal: true } }), {
headers: { 'x-goog-generation': '9' }
})
await channel.poll()
expect(channel.applied().generation).toBe(9)
next = () => streamed('{}', 200, 8)
await channel.poll()
expect(cancelled).toBe(4)
})
it('asks again for a generation whose body was cut off mid-read', async () => {
const object = cellObject(5, { readinessLocal: true })
const google = fakeGoogle(object)
let cut = true
const fetchImpl: typeof fetch = async (target, init) => {
const url = new URL(String(target))
if (url.hostname === 'metadata.google.internal' || !cut) return await google.fetchImpl(target, init)
cut = false
google.state.storageRequests.push(url)
const half = object.body.slice(0, 10)
return new Response(
new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(half))
controller.error(new Error('connection reset'))
}
}),
{ headers: { 'x-goog-generation': '5' } }
)
}
const channel = cellChannel(fetchImpl)
await channel.poll()
expect(channel.applied().generation).toBe(0)
await channel.poll()
expect(google.state.storageRequests[1]?.searchParams.get('ifGenerationNotMatch')).toBeNull()
expect(channel.applied()).toMatchObject({ generation: 5, flags: { readinessLocal: true } })
})
it('reads one at a time and on a jittered 5 s cadence', async () => {
vi.useFakeTimers()
const google = fakeGoogle(cellObject(5, { readinessLocal: true }))
const channel = cellChannel(google.fetchImpl)
await Promise.all([channel.poll(), channel.poll()])
expect(google.state.storageRequests).toHaveLength(1)
const running = cellChannel(google.fetchImpl, true)
await vi.advanceTimersByTimeAsync(0)
expect(google.state.storageRequests).toHaveLength(2)
await vi.advanceTimersByTimeAsync(CONTROL_FLAG_POLL_MS)
expect(google.state.storageRequests).toHaveLength(3)
running.stop()
await vi.advanceTimersByTimeAsync(CONTROL_FLAG_POLL_MS * 3)
expect(google.state.storageRequests).toHaveLength(3)
})
})
@@ -0,0 +1,214 @@
// Runtime switches in one Cloud Storage object per cell (cells/<cellId>.json) in the
// `<project-id>-relay-control` bucket. The bucket name comes from the metadata server, so the
// image needs no env or startup-script change.
//
// Rules: boot runs on defaults until the first good read; losing contact (404, 5xx,
// timeout, malformed body) keeps the applied values; a generation below the applied one is
// ignored; the parser voids a whole object on any bad known value.
const METADATA_ROOT = 'http://metadata.google.internal/computeMetadata/v1'
const STORAGE_ROOT = 'https://storage.googleapis.com/storage/v1'
const REQUEST_TIMEOUT_MS = 3_000
const MAX_OBJECT_BYTES = 64 * 1024
export const CONTROL_FLAG_POLL_MS = 5_000
// Refresh a cached access token this long before Google says it expires.
const ACCESS_TOKEN_EARLY_REFRESH_MS = 60_000
// `ignoredKeys` are names this image does not know: a newer writer's, or a typo.
export type ControlFlagParse<Flags> = (body: unknown) => { flags: Flags; ignoredKeys: string[] } | null
export type AppliedControlFlags<Flags> = { generation: number; flags: Flags; ignoredKeys?: string[] }
export type ControlFlagChannel<Flags> = {
applied: () => AppliedControlFlags<Flags>
poll: () => Promise<void>
stop: () => void
}
type PollFailure = 'metadata' | 'not-found' | 'http' | 'network' | 'malformed' | 'void'
export function startControlFlagChannel<Flags>(input: {
objectName: string
defaults: Flags
parse: ControlFlagParse<Flags>
appliedEvent: string
fetch?: typeof fetch
random?: () => number
pollMs?: number
// Tests drive poll() directly.
autoStart?: boolean
}): ControlFlagChannel<Flags> {
const fetchImpl = input.fetch ?? fetch
const random = input.random ?? Math.random
const pollMs = input.pollMs ?? CONTROL_FLAG_POLL_MS
let applied: AppliedControlFlags<Flags> = { generation: 0, flags: input.defaults }
// The newest generation read, applied or voided: an unchanged void object answers 304.
let seenGeneration = 0
let projectId: string | null = null
let accessToken: { value: string; refreshAt: number } | null = null
let inFlight: Promise<void> | null = null
let lastLoggedFailure: PollFailure | null = null
let timer: ReturnType<typeof setTimeout> | null = null
let stopped = false
const metadata = async (path: string): Promise<Response> =>
await fetchImpl(`${METADATA_ROOT}/${path}`, {
headers: { 'Metadata-Flavor': 'Google' },
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
})
const readProjectId = async (): Promise<string> => {
if (projectId) return projectId
const response = await metadata('project/project-id')
if (!response.ok) void response.body?.cancel().catch(() => undefined)
const value = response.ok ? (await response.text()).trim() : ''
if (!/^[a-z][a-z0-9-]{4,62}$/.test(value)) throw new ControlFlagPollError('metadata')
projectId = value
return value
}
const readAccessToken = async (): Promise<string> => {
if (accessToken && Date.now() < accessToken.refreshAt) return accessToken.value
const response = await metadata('instance/service-accounts/default/token')
if (!response.ok) {
void response.body?.cancel().catch(() => undefined)
throw new ControlFlagPollError('metadata')
}
const body: unknown = await response.json().catch(() => null)
if (
typeof body !== 'object' ||
body === null ||
!('access_token' in body) ||
typeof body.access_token !== 'string' ||
!('expires_in' in body) ||
typeof body.expires_in !== 'number'
) {
throw new ControlFlagPollError('metadata')
}
accessToken = {
value: body.access_token,
refreshAt: Date.now() + body.expires_in * 1_000 - ACCESS_TOKEN_EARLY_REFRESH_MS
}
return body.access_token
}
const readOnce = async (): Promise<void> => {
const bucket = `${await readProjectId()}-relay-control`
const url = new URL(
`${STORAGE_ROOT}/b/${bucket}/o/${encodeURIComponent(input.objectName)}`
)
url.searchParams.set('alt', 'media')
if (seenGeneration > 0) url.searchParams.set('ifGenerationNotMatch', String(seenGeneration))
const response = await fetchImpl(url, {
headers: { authorization: `Bearer ${await readAccessToken()}` },
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
}).catch(() => {
throw new ControlFlagPollError('network')
})
if (response.status === 304) return
// Every early exit releases the body, so an unread reply never pins its socket.
const discard = (): void => void response.body?.cancel().catch(() => undefined)
if (response.status === 401) accessToken = null
if (response.status === 404) {
discard()
throw new ControlFlagPollError('not-found')
}
if (!response.ok) {
discard()
throw new ControlFlagPollError('http')
}
const generation = Number(response.headers.get('x-goog-generation'))
if (!Number.isSafeInteger(generation) || generation <= 0) {
discard()
throw new ControlFlagPollError('malformed')
}
if (generation <= applied.generation) {
discard()
return
}
const text = await readBounded(response, MAX_OBJECT_BYTES)
if (text === null) throw new ControlFlagPollError('malformed')
let body: unknown
try {
body = JSON.parse(text)
} catch {
throw new ControlFlagPollError('malformed')
}
// Only a body read whole is "seen": a read cut off mid-body must be asked for again, or
// every later poll would answer 304 and strand that write.
seenGeneration = generation
const parsed = input.parse(body)
if (parsed === null) throw new ControlFlagPollError('void')
const { flags, ignoredKeys } = parsed
applied = ignoredKeys.length > 0 ? { generation, flags, ignoredKeys } : { generation, flags }
console.log(JSON.stringify({ event: input.appliedEvent, ...applied }))
}
const poll = (): Promise<void> => {
inFlight ??= readOnce()
.then(() => {
lastLoggedFailure = null
})
.catch((error: unknown) => {
const failure = error instanceof ControlFlagPollError ? error.failure : 'network'
// One line per change of cause, never the token or the URL's query.
if (failure === lastLoggedFailure) return
lastLoggedFailure = failure
console.warn(
JSON.stringify({
event: 'orca_relay_control_flags_unreadable',
object: input.objectName,
failure,
generation: applied.generation
})
)
})
.finally(() => {
inFlight = null
})
return inFlight
}
const schedule = (): void => {
if (stopped) return
// ±20% so a fleet that booted together does not read in lockstep.
const delay = Math.round(pollMs * (0.8 + random() * 0.4))
timer = setTimeout(() => void poll().finally(schedule), delay)
timer.unref?.()
}
if (input.autoStart !== false) void poll().finally(schedule)
return {
applied: () => applied,
poll,
stop: () => {
stopped = true
if (timer) clearTimeout(timer)
}
}
}
// Stops reading, and cancels the stream, at the first byte past the bound.
async function readBounded(response: Response, maxBytes: number): Promise<string | null> {
if (!response.body) return ''
const reader = response.body.getReader()
const chunks: Uint8Array[] = []
let total = 0
for (;;) {
const { done, value } = await reader.read()
if (done) break
total += value.byteLength
if (total > maxBytes) {
await reader.cancel().catch(() => undefined)
return null
}
chunks.push(value)
}
return Buffer.concat(chunks).toString('utf8')
}
class ControlFlagPollError extends Error {
constructor(readonly failure: PollFailure) {
super(failure)
}
}
@@ -0,0 +1,182 @@
import { EventEmitter } from 'node:events'
import pg from 'pg'
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
import { keepPostgresClientErrorsHandled, PostgresDatabase } from './database.js'
import {
handleRelayUnhandledRejection,
isFencedRelayDatabaseRejection
} from './relay-database-rejection-fence.js'
class FakePoolClient extends EventEmitter {
constructor(private readonly respond: (sql: string) => unknown) {
super()
}
query = vi.fn(async (sql: string) => this.respond(sql))
release = (): void => {}
}
function databaseFailingWith(failure: unknown, failOn = 'SELECT 1'): PostgresDatabase {
const client = new FakePoolClient((sql) => {
if (sql === failOn) throw failure
return { rows: [], rowCount: 0 }
})
return new PostgresDatabase({
totalCount: 1,
idleCount: 0,
waitingCount: 0,
connect: async () => client
} as never)
}
function databaseFailingToConnect(failure: unknown): PostgresDatabase {
return new PostgresDatabase({
totalCount: 0,
idleCount: 0,
waitingCount: 1,
connect: async () => {
throw failure
}
} as never)
}
function serverError(code: string, message: string): pg.DatabaseError {
const error = new pg.DatabaseError(message, message.length, 'error')
error.code = code
return error
}
// What reaches unhandledRejection when a caller forgets to await the database.
async function rejectionFrom(run: () => Promise<unknown>): Promise<unknown> {
try {
await run()
} catch (error) {
return error
}
throw new Error('expected a rejection')
}
describe('database rejection fence', () => {
let warn: MockInstance<typeof console.warn>
let error: MockInstance<typeof console.error>
beforeEach(() => {
warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
error = vi.spyOn(console, 'error').mockImplementation(() => {})
})
afterEach(() => {
warn.mockRestore()
error.mockRestore()
})
it('fences a constraint violation and a dropped connection from the database layer', async () => {
const duplicate = await rejectionFrom(() =>
databaseFailingWith(serverError('23505', 'duplicate key')).query('SELECT 1')
)
expect(() => handleRelayUnhandledRejection(duplicate)).not.toThrow()
const terminated = await rejectionFrom(() =>
databaseFailingWith(new Error('Connection terminated unexpectedly')).transaction(
async (transaction) => await transaction.query('SELECT 1')
)
)
expect(() => handleRelayUnhandledRejection(terminated)).not.toThrow()
const fenced = warn.mock.calls
.map((call) => String(call[0]))
.filter((line) => line.includes('"event":"orca_relay_database_rejection_fenced"'))
expect(fenced).toHaveLength(2)
expect(fenced[0]).toContain('"code":"23505"')
expect(error).not.toHaveBeenCalled()
})
it('keeps every rejection fatal while the cell switch has the fence off', async () => {
const duplicate = await rejectionFrom(() =>
databaseFailingWith(serverError('23505', 'duplicate key')).query('SELECT 1')
)
expect(() => handleRelayUnhandledRejection(duplicate, false)).toThrow()
expect(error).toHaveBeenCalledTimes(1)
expect(() => handleRelayUnhandledRejection(duplicate, true)).not.toThrow()
})
// A whole-VM stall of 6-7 s outlasts the 5 s idle-in-transaction limit for any backend caught
// idle mid-transaction, and the server ends that session with FATAL 25P03 on resume.
it('fences an idle-in-transaction timeout but keeps the rest of class 25 fatal', async () => {
const idleTimeout = await rejectionFrom(() =>
databaseFailingWith(
serverError('25P03', 'terminating connection due to idle-in-transaction timeout')
).transaction(async (transaction) => await transaction.query('SELECT 1'))
)
expect(isFencedRelayDatabaseRejection(idleTimeout)).toBe(true)
const abortedTransaction = await rejectionFrom(() =>
databaseFailingWith(
serverError('25P02', 'current transaction is aborted, commands ignored')
).query('SELECT 1')
)
expect(isFencedRelayDatabaseRejection(abortedTransaction)).toBe(false)
})
it('fences every failure to get a pooled client, including bad credentials', async () => {
const badPassword = serverError('28P01', 'password authentication failed')
const failure = await rejectionFrom(() =>
databaseFailingToConnect(badPassword).transaction(async () => undefined)
)
expect(failure).toBe(badPassword)
expect(isFencedRelayDatabaseRejection(failure)).toBe(true)
})
it('keeps schema errors, pg TypeErrors and a lock-unavailable answer fatal', async () => {
const missingTable = await rejectionFrom(() =>
databaseFailingWith(serverError('42P01', 'relation does not exist')).query('SELECT 1')
)
const serialisation = await rejectionFrom(() =>
databaseFailingWith(new TypeError('Cannot convert a BigInt value')).query('SELECT 1')
)
const lockUnavailable = await rejectionFrom(() =>
databaseFailingWith(serverError('55P03', 'could not obtain lock'), 'SELECT 1 FOR UPDATE NOWAIT')
.transaction(async (transaction) =>
await transaction.queryLocked('SELECT 1', [], { failIfUnavailable: true })
)
)
expect(lockUnavailable).toEqual(new Error('database_lock_unavailable'))
for (const reason of [missingTable, serialisation, lockUnavailable]) {
expect(() => handleRelayUnhandledRejection(reason)).toThrow()
}
expect(error.mock.calls.map((call) => String(call[0]))).toEqual([
expect.stringContaining('"event":"orca_relay_process_fatal"'),
expect.stringContaining('"event":"orca_relay_process_fatal"'),
expect.stringContaining('"event":"orca_relay_process_fatal"')
])
})
it('fences a socket reset or broken pipe thrown mid-statement', async () => {
for (const code of ['ECONNRESET', 'EPIPE']) {
const dropped = Object.assign(new Error(`read ${code}`), { code })
const reason = await rejectionFrom(() =>
databaseFailingWith(dropped).transaction(
async (transaction) => await transaction.query('SELECT 1')
)
)
expect(reason).toBe(dropped)
expect(() => handleRelayUnhandledRejection(reason)).not.toThrow()
}
expect(error).not.toHaveBeenCalled()
})
it('keeps a database-looking error fatal when the database layer never saw it', () => {
const socketReset = Object.assign(new Error('write EPIPE'), { code: 'EPIPE' })
const unmarked = serverError('23505', 'duplicate key')
const terminated = new Error('Connection terminated unexpectedly')
for (const reason of [socketReset, unmarked, terminated, undefined, 'text']) {
expect(isFencedRelayDatabaseRejection(reason)).toBe(false)
}
expect(() => handleRelayUnhandledRejection(socketReset)).toThrow(socketReset)
})
})
describe('permanent PostgreSQL client error listener', () => {
it('leaves every client with a listener for its whole life', () => {
const pool = new EventEmitter()
keepPostgresClientErrorsHandled(pool as never)
const client = new EventEmitter()
pool.emit('connect', client)
expect(() => client.emit('error', new Error('Connection terminated unexpectedly'))).not.toThrow()
})
})
@@ -0,0 +1,86 @@
import pg from 'pg'
import {
isPostgresPoolAcquireFailure,
isPostgresPoolConnectFailure
} from './postgres-pool-pressure.js'
// A database fault reaching unhandledRejection must not kill the cell: on 09-28 one failover
// crashed 18 cells and dropped ~16k hosts. A rejection is fenced only when both hold:
// path: the relay's database layer threw or rethrew it (marked below), and
// type: a pool acquire failure, pg's read timeout, a dropped connection, or a server error
// in class 08/40/53/55/57/58 (the database is unwell) or 23 (a constraint lost a race
// with a concurrent writer; the row it guards is intact, so the process is too).
// Everything else stays fatal: schema errors (42), data errors (22), TypeErrors from pg's
// parameter serialisation, and every bare socket errno from ws or fetch.
const databaseLayerErrors = new WeakSet<object>()
// Set by pg's query_timeout timer (pg@8.22 client.js).
export const POSTGRES_READ_TIMEOUT_MESSAGE = 'Query read timeout'
const CONNECTION_TERMINATED_PREFIX = 'Connection terminated'
// pg@8.22 rejects the active query with the raw socket error when the connection drops.
const DROPPED_SOCKET_ERRNOS = new Set(['ECONNRESET', 'EPIPE'])
const FENCED_SQLSTATE_CLASSES = new Set(['08', '23', '40', '53', '55', '57', '58'])
// Class 25 is otherwise a code error (a statement in the wrong transaction state). 25P03 is the
// server ending a session left idle in a transaction past 5 s, which a whole-VM stall of 6-7 s
// does to any backend caught idle mid-transaction at its onset.
const FENCED_SQLSTATES = new Set(['25P03'])
export function isPostgresReadTimeout(error: unknown): error is Error {
return error instanceof Error && error.message === POSTGRES_READ_TIMEOUT_MESSAGE
}
// The database layer threw or rethrew this; an errno or message alone could be any socket's.
export function isRelayDatabaseLayerError(error: unknown): boolean {
if (typeof error !== 'object' || error === null) return false
return databaseLayerErrors.has(error) || isPostgresPoolAcquireFailure(error)
}
export function markRelayDatabaseError(error: unknown): void {
if (typeof error === 'object' && error !== null) databaseLayerErrors.add(error)
}
export function isFencedRelayDatabaseRejection(reason: unknown): boolean {
if (typeof reason !== 'object' || reason === null) return false
// Any acquire failure, including a 28P01 at connect: no statement ran, so nothing is unknown.
if (isPostgresPoolAcquireFailure(reason)) return true
if (!databaseLayerErrors.has(reason)) return false
if (isPostgresPoolConnectFailure(reason)) return true
if (reason instanceof pg.DatabaseError) {
const code = String(reason.code)
return FENCED_SQLSTATE_CLASSES.has(code.slice(0, 2)) || FENCED_SQLSTATES.has(code)
}
if (!(reason instanceof Error) || reason instanceof TypeError) return false
return (
('code' in reason && DROPPED_SOCKET_ERRNOS.has(String(reason.code))) ||
reason.message === POSTGRES_READ_TIMEOUT_MESSAGE ||
reason.message.startsWith(CONNECTION_TERMINATED_PREFIX)
)
}
function rejectionFields(reason: unknown): { code: string; message: string } {
const error = typeof reason === 'object' && reason !== null ? reason : {}
const code = 'code' in error ? String(error.code) : ''
const message = 'message' in error ? String(error.message) : String(reason)
// Server messages can quote values; the code and the start of the message identify the fault.
return { code, message: message.replace(/[^\x20-\x7e]/g, '').slice(0, 120) }
}
// Installed once by the process entry point. Fatal rejections are rethrown, which ends the
// process exactly as Node's default would, after one line that names them.
// `fenced` false is the per-cell switch turned off: every rejection is fatal, as before #26817.
export function handleRelayUnhandledRejection(reason: unknown, fenced = true): void {
if (fenced && isFencedRelayDatabaseRejection(reason)) {
console.warn(
JSON.stringify({ event: 'orca_relay_database_rejection_fenced', ...rejectionFields(reason) })
)
return
}
console.error(
JSON.stringify({
event: 'orca_relay_process_fatal',
kind: 'unhandled-rejection',
...rejectionFields(reason)
})
)
throw reason
}
+5 -4
View File
@@ -1,5 +1,6 @@
// Bump by one in any change that fixes a cell crash or a cell safety bug. Every runtime
// metrics line carries it, and an alert pages on a serving cell left below the newest
// level any cell reports, so a fleet that silently kept an unfixed image gets caught.
// Images from before this constant report nothing, which the alert reads as below.
export const RELAY_FIX_LEVEL = 1
// metrics line carries it, and an alert pages on a serving cell that reports a level below
// the fixed floor `relay_cell_min_fix_level` (production.tfvars) for 6 hours; raise the floor
// once every serving cell runs the new level. Images from before this constant report nothing,
// which the alert reads as below.
export const RELAY_FIX_LEVEL = 2
@@ -0,0 +1,191 @@
import { createServer } from 'node:http'
import type { Server } from 'node:net'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { CELL_FLAG_DEFAULTS, type CellFlags } from './cell-flags.js'
import type { RelayConfig } from './config.js'
import type { RelayDatabase } from './database.js'
import type { AppliedControlFlags } from './relay-control-flag-channel.js'
import { createRelayLocalReadiness } from './relay-local-readiness.js'
import { createRelayServer } from './relay-server.js'
import { createRelayApp } from './app.js'
describe('local readiness verdict', () => {
it('is not ready before the server listens', () => {
const verdict = createRelayLocalReadiness({
listening: () => false,
keys: { jwks: () => ({ keys: [] }), reload: vi.fn(async () => {}) },
failingDependencies: () => []
})
expect(verdict()).toEqual({ ready: false, reason: 'not_listening' })
})
it('loads the verifier keys itself, once at a time, and is ready after they land', async () => {
let keys: unknown
let finishLoad!: () => void
const reload = vi.fn(
() =>
new Promise<void>((resolve) => {
finishLoad = () => {
keys = { keys: [] }
resolve()
}
})
)
const verdict = createRelayLocalReadiness({
listening: () => true,
keys: { jwks: () => keys, reload },
failingDependencies: () => ['sql']
})
expect(verdict()).toEqual({ ready: false, reason: 'keys_not_loaded' })
expect(verdict()).toEqual({ ready: false, reason: 'keys_not_loaded' })
expect(reload).toHaveBeenCalledTimes(1)
finishLoad()
await Promise.resolve()
expect(verdict()).toEqual({ ready: true, failing: ['sql'] })
})
it('retries a key load that failed', async () => {
const reload = vi.fn(async () => {
throw new Error('jwks unreachable')
})
const verdict = createRelayLocalReadiness({
listening: () => true,
keys: { jwks: () => undefined, reload },
failingDependencies: () => []
})
verdict()
await vi.waitFor(() => expect(reload).toHaveBeenCalledTimes(1))
await new Promise((resolve) => setTimeout(resolve, 0))
verdict()
expect(reload).toHaveBeenCalledTimes(2)
})
})
// E-dep-3 in miniature: the database is down from boot, so it never enters a grace window.
describe('cell /ready with the database down', () => {
const servers: Server[] = []
afterEach(async () => {
for (const server of servers.splice(0)) {
if ('closeAllConnections' in server && typeof server.closeAllConnections === 'function') {
server.closeAllConnections()
}
await new Promise<void>((resolve) => server.close(() => resolve()))
}
})
async function listen(server: Server): Promise<string> {
servers.push(server)
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
const address = server.address()
if (typeof address !== 'object' || address === null) throw new Error('not listening')
return `http://127.0.0.1:${address.port}`
}
async function startCell(applied: () => AppliedControlFlags<CellFlags>) {
const jwksBase = await listen(
createServer((_request, response) => {
response.setHeader('content-type', 'application/json')
response.end('{"keys":[]}')
})
)
const relay = createRelayServer(cellConfig(`${jwksBase}/jwks`), unreachableDatabase(), {
cellFlags: applied
})
const base = await listen(relay.server)
return { relay, base }
}
it('stays out of rotation with the switch off', async () => {
const { relay, base } = await startCell(() => ({ generation: 0, flags: CELL_FLAG_DEFAULTS }))
const response = await fetch(`${base}/ready`)
expect(response.status).toBe(503)
expect(await response.json()).toEqual({ error: 'dependency_unavailable' })
expect(await relay.ready()).toBe(false)
})
it('answers 200 with the switch on, while the heartbeat keeps the SQL verdict', async () => {
const { relay, base } = await startCell(() => ({
generation: 3,
flags: { ...CELL_FLAG_DEFAULTS, readinessLocal: true }
}))
// The first answer starts the verifier's own key load.
expect(await (await fetch(`${base}/ready`)).json()).toEqual({ error: 'keys_not_loaded' })
await vi.waitFor(async () => expect((await fetch(`${base}/ready`)).status).toBe(200))
expect(await (await fetch(`${base}/ready`)).json()).toEqual({
ok: true,
degraded: true,
dependency: ['sql']
})
expect(await relay.ready()).toBe(false)
})
})
function unreachableDatabase(): RelayDatabase {
const refuse = async (): Promise<never> => {
throw new Error('connect ECONNREFUSED')
}
return {
dialect: 'postgres',
query: refuse,
queryLocked: refuse,
transaction: refuse,
close: async () => {}
}
}
function cellConfig(jwksUrl: string): RelayConfig {
return {
port: 0,
publicUrl: 'https://c7.relay.example.test',
cellUrl: 'https://c7.relay.example.test',
region: 'us-central1',
authIssuer: 'https://auth.example.test',
authAudience: 'orca-relay',
jwksUrl,
assignmentSigningKey: new Uint8Array(32),
role: 'cell',
cellId: 'production-gce-c7',
cells: [],
adminAudience: 'https://relay.example.test/v1/admin/drain',
deployServiceAccount: 'deploy@example.test',
runtimeServiceAccount: 'relay-cell@example.test',
adminJwksUrl: jwksUrl,
databasePoolMax: 10,
publicAssignmentsEnabled: true,
publicAssignmentConcurrency: 2,
publicAssignmentQueueMax: 128,
publicAssignmentWaitMs: 4_000,
publicResolveConcurrency: 1,
publicResolveWaitMs: 5_000,
publicAssignmentRetryAfterSeconds: 5,
dataDir: './data'
}
}
describe('one host-token key set per process', () => {
it('verifies HTTP relay tokens with the key set the server shares', async () => {
const keys = vi.fn(async () => {
throw new Error('no key in this test')
})
const app = createRelayApp(
{ ...cellConfig('https://auth.example.test/jwks'), role: 'director', cellId: 'director' },
{
store: {} as never,
assignments: {} as never,
drain: vi.fn(),
ready: vi.fn(async () => true),
// SAFETY: jwtVerify only calls the key set; the jwks/reload members are unused here.
relayJwks: keys as never
}
)
const encode = (value: object) => Buffer.from(JSON.stringify(value)).toString('base64url')
const token = `${encode({ alg: 'ES256' })}.${encode({ sub: 'user-1' })}.c2ln`
const response = await app.request('/v1/assign', {
method: 'POST',
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
body: JSON.stringify({ v: 1, relayHostId: 'abcdefghijklmnop' })
})
expect(response.status).toBe(401)
expect(keys).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,32 @@
import type { RelayReadinessDependency } from './relay-readiness.js'
export type RelayLocalReadinessVerdict =
| { ready: true; failing: RelayReadinessDependency[] }
| { ready: false; reason: 'not_listening' | 'keys_not_loaded' }
// `readinessLocal`: the load balancer keeps a cell that can still serve its connected hosts and
// verify new tokens from cached keys, so a database or auth outage costs a per-hello refusal
// instead of the whole hostname. The heartbeat keeps the SQL verdict, so directors still stop
// placing here.
export function createRelayLocalReadiness(input: {
listening: () => boolean
// The host token verifier's own key set: a fetch by any other client proves nothing about it.
keys: { jwks: () => unknown; reload: () => Promise<void> }
failingDependencies: () => RelayReadinessDependency[]
}): () => RelayLocalReadinessVerdict {
let loading: Promise<void> | null = null
return () => {
if (!input.listening()) return { ready: false, reason: 'not_listening' }
if (input.keys.jwks() === undefined) {
// Keys load lazily on the first hello, which a cell outside rotation never gets.
loading ??= input.keys
.reload()
.catch(() => undefined)
.finally(() => {
loading = null
})
return { ready: false, reason: 'keys_not_loaded' }
}
return { ready: true, failing: input.failingDependencies() }
}
}
@@ -63,6 +63,10 @@ export type AssignmentUnavailableCause =
| 'relay_connection_headroom_exhausted'
| 'relay_home_cell_unavailable'
| 'relay_assignment_row_busy'
// Step 5: a starting director's map, a never-seen host with the database down, or intake.
| 'reserve-map-incomplete'
| 'reserve-database'
| 'reserve-paced'
// Row-lock waiters seen in one pg_stat_activity sample, by who waits, on which
// table, behind whom. Roles come from each pool's application_name.
+11 -1
View File
@@ -31,6 +31,8 @@ export type RelayReadinessGraceEvent = {
export type RelayReadinessProbe = {
check: () => Promise<boolean>
degradedDependencies: () => RelayReadinessDependency[]
// Failing on the last probe, in grace or past it.
failingDependencies: () => RelayReadinessDependency[]
}
// The token verifier caches keys in process, so a cell keeps verifying tokens right through a JWKS
@@ -134,6 +136,7 @@ export function createRelayReadiness(
let pending: Promise<boolean> | null = null
let lastObservedReady: boolean | undefined
let degraded: RelayReadinessDependency[] = []
let failing: RelayReadinessDependency[] = []
const probeJwks = async (): Promise<RelayReadinessFailure | undefined> => {
try {
@@ -163,6 +166,9 @@ export function createRelayReadiness(
const failures = [jwks.value, sql.value].filter((value) => value !== undefined)
const failure = failures[0]
degraded = []
failing = []
if (jwks.value !== undefined) failing.push('jwks')
if (sql.value !== undefined) failing.push('sql')
if (jwksSettlement.degraded) degraded.push('jwks')
if (sqlSettlement.degraded) degraded.push('sql')
cached = jwksSettlement.satisfied && sqlSettlement.satisfied
@@ -193,5 +199,9 @@ export function createRelayReadiness(
return pending
}
return { check, degradedDependencies: () => [...degraded] }
return {
check,
degradedDependencies: () => [...degraded],
failingDependencies: () => [...failing]
}
}
@@ -190,6 +190,47 @@ describePostgres('relay boot-time schema against PostgreSQL', () => {
}
})
it('throttles the reservations vacuum, deferring it while a vacuum holds the table', async () => {
const reservationOptions = async (): Promise<unknown> =>
(
await pool.query(`SELECT reloptions FROM pg_class WHERE oid = to_regclass($1)`, [
`${schema}.${LOCKED_TABLE}`
])
).rows[0]?.reloptions
const cold = await openRelayDatabase({ databaseUrl: url, dataDir: '' })
opened.push(cold)
expect(await reservationOptions()).toEqual([
'autovacuum_vacuum_cost_delay=20',
'autovacuum_vacuum_cost_limit=200'
])
await pool.query(
`ALTER TABLE ${schema}.${LOCKED_TABLE}
RESET (autovacuum_vacuum_cost_delay, autovacuum_vacuum_cost_limit)`
)
// The lock a running (auto)vacuum holds; the boot must yield to it rather than fail.
const holder = new pg.Client({ connectionString: url })
await holder.connect()
await holder.query('BEGIN')
await holder.query(`LOCK TABLE ${LOCKED_TABLE} IN SHARE UPDATE EXCLUSIVE MODE`)
try {
const behindVacuum = await openRelayDatabase({ databaseUrl: url, dataDir: '' })
opened.push(behindVacuum)
} finally {
await holder.query('ROLLBACK')
await holder.end()
}
expect(await reservationOptions()).toBeNull()
const next = await openRelayDatabase({ databaseUrl: url, dataDir: '' })
opened.push(next)
expect(await reservationOptions()).toEqual([
'autovacuum_vacuum_cost_delay=20',
'autovacuum_vacuum_cost_limit=200'
])
await pool.end()
})
it('defers the activity-lease migrations and still boots while their table is locked', async () => {
// The migration boot, reproduced: the index is there, the table is locked by someone else, and
// all the drop can do is time out. It has to leave the statement for the next boot rather than
@@ -134,6 +134,18 @@ const GOLDEN_LOCK_TAKING: SchemaLockTarget[] = [
table: 'relay_assignment_activity_leases',
name: 'fillfactor=70',
skipWhen: 'present'
},
{
kind: 'reloption',
table: 'relay_control_connection_reservations',
name: 'autovacuum_vacuum_cost_delay=20',
skipWhen: 'present'
},
{
kind: 'reloption',
table: 'relay_control_connection_reservations',
name: 'autovacuum_vacuum_cost_limit=200',
skipWhen: 'present'
}
]
@@ -240,7 +252,7 @@ describe('relay boot-time lock targets', () => {
}
})
it('marks the out-of-band sweep indexes and the activity-lease migrations deferrable, and nothing else', () => {
it('marks the out-of-band sweep indexes, the activity-lease migrations and the reservation vacuum throttle deferrable, and nothing else', () => {
// The statements a lock timeout must not turn into a crash loop, and the only ones: every
// other statement still fails the boot loudly, which is what keeps the marker meaningful.
const deferrable = relayPostgresSchemaStatements().filter(schemaDeferrable)
@@ -250,7 +262,9 @@ describe('relay boot-time lock targets', () => {
'CREATE INDEX IF NOT EXISTS relay_direct_authorizations_pending_deadline ON relay_direct_authorizations(deadline) WHERE consumed_at IS NULL',
'CREATE INDEX IF NOT EXISTS relay_rate_windows_started ON relay_rate_windows(window_started_at)',
'DROP INDEX IF EXISTS relay_assignment_activity_expiry',
'ALTER TABLE relay_assignment_activity_leases SET (fillfactor = 70)'
'ALTER TABLE relay_assignment_activity_leases SET (fillfactor = 70)',
'ALTER TABLE relay_control_connection_reservations SET (autovacuum_vacuum_cost_delay = 20)',
'ALTER TABLE relay_control_connection_reservations SET (autovacuum_vacuum_cost_limit = 200)'
])
})
+307 -26
View File
@@ -3,20 +3,30 @@ import {
hasAdmissionCapacity,
HostDataAuthSchema,
parseRelayHostCapabilities,
RELAY_ASSIGNMENT_LEASE_HEADER,
RELAY_ADMISSION_BUDGETS,
RELAY_HOST_CAPABILITIES_HEADER,
RELAY_CLOSE_CODE,
RELAY_DEFAULT_REGION,
RELAY_PROTOCOL_LIMITS,
RelayAuthSchema
RelayAuthSchema,
cellPlacementCeiling
} from '@orca-cloud/relay-contract'
import type { IncomingMessage } from 'node:http'
import { randomUUID } from 'node:crypto'
import { performance } from 'node:perf_hooks'
import { createRemoteJWKSet } from 'jose'
import { WebSocketServer } from 'ws'
import type WebSocket from 'ws'
import type { RawData } from 'ws'
import { CellAdmitEffectiveWriter } from './cell-admit-effective-writer.js'
import { ReserveDeadMan, reserveDeadManWindowMs } from './cell-reserve-dead-man.js'
import { createRelayApp } from './app.js'
import { classifyAssignmentLease } from './assignment-lease.js'
import { defaultIntakePerSec, type CellFlags } from './cell-flags.js'
import { CellReserveBook } from './cell-reserve-book.js'
import type { DemoteRequest, ReserveRequest } from './cell-reserve-contract.js'
import { AssignmentLeaseShadow } from './assignment-lease-shadow.js'
import { RelayAssignmentStore } from './assignment-store.js'
import type { RelayConfig } from './config.js'
import { RelayCredentialStore } from './credential-store.js'
@@ -30,9 +40,23 @@ import { observeRelayDatabase } from './observed-relay-database.js'
import { RelayObservability } from './relay-observability.js'
import { combineRegionalRehomeSafety } from './regional-rehome-safety.js'
import { RelayConnectionLedger, type RelayConnectionUpgrade } from './relay-connection-ledger.js'
import { PlacementLoadBand } from './placement-load-band.js'
import type { AppliedControlFlags } from './relay-control-flag-channel.js'
import { createRelayLocalReadiness } from './relay-local-readiness.js'
import { createRelayReadiness } from './relay-readiness.js'
import { createRelayTokenVerifier, readBearer } from './relay-token-verifier.js'
import { closeRelayWebSocket } from './relay-websocket-close.js'
import { ShadowDirectoryCompare } from './shadow-directory-compare.js'
import { startShadowSeatPoller } from './shadow-seat-directory.js'
import { CellReserveClient } from './cell-reserve-client.js'
import { googleMetadataIdentityToken, reusedIdentityToken } from './google-metadata-identity-token.js'
import { ReserveAssignment } from './reserve-assignment.js'
import { ReservePlacer } from './reserve-placement.js'
import {
reconcileReserveLedger,
RESERVE_LEDGER_RECONCILE_MS,
ReserveLedgerWriter
} from './reserve-ledger-writer.js'
import { ProcessQueuedByteBudget } from './splice-forwarder.js'
// A malformed percent-escape in the request target must be a client error, never a URIError
@@ -51,6 +75,10 @@ function decodePathSegment(value: string): string | null {
// tripping it. On 10-07 every window that met both also logged SQL failures.
export const HOST_HELLO_SHED_OLDEST_WAIT_MS = 1_000
const HOST_HELLO_SHED_RETRY_AFTER_SECONDS = 2
// Well inside the flag channel's 5 s read, so a flip back starts re-registering within ~6 s.
export const RESERVE_MODE_WATCH_MS = 1_000
// Shipped desktops ignore it; it is for the ones that learn to read it.
const CELL_FULL_RETRY_AFTER_SECONDS = 2
function rejectUpgrade(
socket: NodeJS.WritableStream,
@@ -80,6 +108,10 @@ function guardSocketErrors(socket: WebSocket, kind: string): void {
})
}
function firstHeader(value: string | string[] | undefined): string | undefined {
return Array.isArray(value) ? value[0] : value
}
function admissionSource(request: IncomingMessage): string {
const forwarded = request.headers['x-forwarded-for']
const chain = (Array.isArray(forwarded) ? forwarded.join(',') : (forwarded ?? ''))
@@ -110,6 +142,7 @@ export function createRelayServer(
random?: () => number
connectionLedgerLimits?: { hardCap: number; controlReserve: number }
cellIncarnation?: string
cellFlags?: () => AppliedControlFlags<CellFlags>
} = {}
) {
const cellIncarnation = options.cellIncarnation ?? randomUUID()
@@ -125,10 +158,12 @@ export function createRelayServer(
perMessageDeflate: false,
maxPayload: 1024 * 1024
})
const verifyRelayToken = createRelayTokenVerifier(config)
const relayJwks = createRemoteJWKSet(new URL(config.jwksUrl))
const verifyRelayToken = createRelayTokenVerifier(config, relayJwks)
const store = new RelayCredentialStore(observedDatabase, options.now)
const assignments = new RelayAssignmentStore(observedDatabase, options.now, {
requireLiveCells: config.role === 'director',
placementLoadBand: config.role === 'director' ? new PlacementLoadBand(options.random) : undefined,
regionalRehomeCohortPercent: config.regionCorrectionCohortPercent ?? 0,
// The director runs in the database's region; only its rehome readers use this.
regionalRehomeDirectorRegion:
@@ -143,6 +178,174 @@ export function createRelayServer(
observeGrace: (event) => observability.recordReadinessGrace(event)
})
const ready = readiness.check
// Set once placement is built: a director configured `on` that could not build it (no map or
// no rehome credential) books nothing, so it must not keep a reserve cell's dead-man fed.
const placing = { on: false }
const shadowSeatPoller = startShadowSeatPoller(config, {
listCells: () => assignments.seatFeedCells(),
reserver: () => placing.on
})
const shadowCompare = shadowSeatPoller
? new ShadowDirectoryCompare(shadowSeatPoller.directory, options.now)
: undefined
// Step 5 on directors: needs the map and the rehome credential the cells verify.
const directorId = randomUUID()
const rehomeAudience = config.rehomeAudience
// Only a director that books writes rows, and only for the joins it booked.
const reserveLedger =
config.role === 'director' && shadowSeatPoller && config.reservePlacement === 'on'
? new ReserveLedgerWriter(observedDatabase, { now: options.now })
: undefined
const reservePlacement =
config.role === 'director' &&
shadowSeatPoller &&
rehomeAudience &&
(config.reservePlacement ?? 'off') !== 'off'
? new ReserveAssignment({
mode: config.reservePlacement ?? 'off',
directory: shadowSeatPoller.directory,
cells: shadowSeatPoller.cells,
startedAt: shadowSeatPoller.startedAt,
placer: new ReservePlacer(options.now, options.random),
client: new CellReserveClient({
directorId,
identityToken: reusedIdentityToken(
() => googleMetadataIdentityToken(rehomeAudience),
options.now
)
}),
readRow: async (identity) => await assignments.hostWhereabouts(identity),
databaseBusy: () => readRelayDatabasePoolPressure(database).databasePoolWaiting > 0,
now: options.now,
random: options.random
})
: undefined
placing.on = reservePlacement?.placementMode === 'on'
// A director that is not placing while a cell is in reserve mode strands that cell's hosts on
// its memory: its dead-man flips it back, and this says so loudly until then.
const offWithReserveTimer =
config.role === 'director' && shadowSeatPoller && !placing.on
? setInterval(() => {
const { directory } = shadowSeatPoller
const cells = directory.cellIds().filter((cellId) => directory.admitModeOf(cellId) === 'reserve')
if (cells.length === 0) return
console.error(
JSON.stringify({
event: 'orca_relay_reserve_placement_off_with_reserve_cells',
cells,
placement: reservePlacement?.placementMode ?? 'off',
configured: config.reservePlacement ?? 'off'
})
)
}, 60_000)
: null
offWithReserveTimer?.unref()
if (reserveLedger && shadowSeatPoller) {
const directory = shadowSeatPoller.directory
directory.onBookedJoin = (join) => {
if (join.reservedBy !== directorId) return
reserveLedger.enqueue({
userId: join.userId,
relayHostId: join.relayHostId,
cellId: join.cellId,
epoch: join.epoch
})
}
reserveLedger.start()
const reconcile = (): void => {
const now = (options.now ?? Date.now)()
void reconcileReserveLedger({
writer: reserveLedger,
seats: () =>
directory
.cellIds()
.filter((cellId) => directory.admitModeOf(cellId) === 'reserve')
.flatMap((cellId) =>
directory.seatsOnCell(cellId).map(({ userId, relayHostId, seat }) => ({
userId,
relayHostId,
cellId,
epoch: seat.epoch,
joinedAt: seat.joinedAt
}))
),
demote: (seat, row) => {
reservePlacement?.demoteSeat(seat)
// The re-assign mints above the row, so the mirror can take it.
reservePlacement?.raiseEpochFloor(seat, row.epoch)
console.warn(
JSON.stringify({
event: 'orca_relay_reserve_ledger_seat_behind_row',
cellId: seat.cellId,
epoch: seat.epoch,
rowEpoch: row.epoch
})
)
},
now
})
.catch(() => console.warn('[orca-relay] reserve ledger reconcile deferred'))
.finally(schedule)
}
// Jittered, so five directors do not reconcile together.
const schedule = (): void => {
const random = options.random ?? Math.random
setTimeout(reconcile, RESERVE_LEDGER_RECONCILE_MS + random() * 60_000).unref?.()
}
// A restart loses the queue: the first run goes as soon as the map is complete.
const clock = options.now ?? Date.now
const bootedAt = clock()
const firstRun = setInterval(() => {
if (!directory.isComplete() && clock() - bootedAt < RESERVE_LEDGER_RECONCILE_MS) return
clearInterval(firstRun)
reconcile()
}, 1_000)
firstRun.unref?.()
}
const configuredConnectionLimits =
config.connectionHardCap === undefined
? null
: (options.connectionLedgerLimits ?? {
hardCap: config.connectionHardCap,
controlReserve: RELAY_ADMISSION_BUDGETS.reservedHostControls
})
const connectionLedger =
configuredConnectionLimits === null
? null
: new RelayConnectionLedger(
configuredConnectionLimits.hardCap,
configuredConnectionLimits.controlReserve
)
// Step 5: a booking may fill the cell only to the ceiling the directors place against.
const placementCeiling =
config.connectionHardCap === undefined || config.connectionUnobservedBound === undefined
? null
: cellPlacementCeiling(config.connectionHardCap, config.connectionUnobservedBound)
const reserveBook =
config.role === 'cell' && connectionLedger && placementCeiling !== null
? new CellReserveBook(
{
tryReserve: () => connectionLedger.tryReserveBooking(placementCeiling),
canReserve: () => connectionLedger.canReserveBooking(placementCeiling)
},
() =>
options.cellFlags?.().flags.intakePerSec ?? defaultIntakePerSec(config.region),
options.now
)
: null
// The hello shed's test: the pool is already timing queries out.
const databaseShedding = (): boolean =>
readRelayDatabasePoolPressure(database).databasePoolWaiting >= config.databasePoolMax &&
readRelayDatabasePoolOldestWaitMs(database) >= HOST_HELLO_SHED_OLDEST_WAIT_MS
const reserveDeadMan = reserveBook
? new ReserveDeadMan(config.cellId, options.now, reserveDeadManWindowMs(config.cellId))
: null
// The switch file's admitMode, unless the dead-man has flipped this cell back.
const effectiveAdmitMode = (): 'db' | 'reserve' => {
const applied = options.cellFlags?.()
if (!applied) return 'db'
return reserveDeadMan ? reserveDeadMan.mode(applied) : applied.flags.admitMode
}
const queuedBytes = new ProcessQueuedByteBudget()
const sessions = new HostSessionRegistry(
config,
@@ -153,8 +356,56 @@ export function createRelayServer(
observability,
options.now,
options.random,
cellIncarnation
cellIncarnation,
new AssignmentLeaseShadow({
enabled: () => options.cellFlags?.().flags.ticketCheck === 'shadow',
key: config.assignmentSigningKey,
cellId: config.cellId
}),
reserveBook
? {
mode: effectiveAdmitMode,
directorContact: () => reserveDeadMan?.contact(),
reregisterInFlight: () => options.cellFlags?.().flags.reregisterInFlight,
ticketEnforce: () => options.cellFlags?.().flags.ticketCheck === 'enforce',
dryRunEnabled: () => options.cellFlags?.().flags.reserveDryRun === true,
databaseShedding: () => databaseShedding(),
book: reserveBook,
verifyLease: (input) =>
classifyAssignmentLease({
...input,
key: config.assignmentSigningKey,
cellId: config.cellId
})
}
: undefined
)
// Expired bookings give their units back even while no director is reserving. A flip out of
// reserve mode voids the bookings and registers every control admitted from memory.
let appliedAdmitMode = effectiveAdmitMode()
const admitEffective =
config.role === 'cell' && reserveBook
? new CellAdmitEffectiveWriter({
write: async (mode) =>
await assignments.recordCellAdmitEffective({ cellId: config.cellId, cellIncarnation, mode }),
databaseBusy: () => readRelayDatabasePoolPressure(database).databasePoolWaiting > 0,
now: options.now ?? Date.now
})
: undefined
const reserveSweepTimer = reserveBook
? setInterval(() => {
reserveBook.sweep()
const admitMode = effectiveAdmitMode()
if (appliedAdmitMode === 'reserve' && admitMode !== 'reserve') {
reserveBook.clear()
sessions.reregisterMemoryControls()
}
appliedAdmitMode = admitMode
// A booted cell runs db defaults until its first flag read, so it writes nothing until then.
admitEffective?.tick(admitMode, options.cellFlags?.().flags.admitMode === 'reserve')
}, RESERVE_MODE_WATCH_MS)
: null
reserveSweepTimer?.unref()
const app = createRelayApp(config, {
store,
assignments,
@@ -175,6 +426,24 @@ export function createRelayServer(
},
regionalRehomeTrustProbeHostExists: (input) => sessions.get(input) !== null,
cellIncarnation,
cellSeatFeed: (sinceSeq) => sessions.seatFeed(sinceSeq),
cellFlags: options.cellFlags,
...(reserveBook && placementCeiling !== null
? {
cellReserve: (request: ReserveRequest) => sessions.reserve(request),
cellReserverPoll: () => reserveDeadMan?.contact(),
// Reserve until every control it admitted from memory holds a lease again: the flag
// workflow records db in Postgres (and sweeps resume) only after this says db.
cellAdmitModeRaw: effectiveAdmitMode,
cellAdmitModeEffective: (): 'db' | 'reserve' =>
effectiveAdmitMode() === 'reserve' || sessions.reregistrationPending() > 0
? 'reserve'
: 'db',
cellDemote: (request: DemoteRequest) => sessions.demote(request),
cellReserveCounts: () => sessions.reserveCounts(),
cellPlacementCeiling: placementCeiling
}
: {}),
isDraining: () => sessions.isDraining(),
runtimeCounts: () => runtimeCounts(),
regionalRehomeSafetySnapshot: () => ({
@@ -183,6 +452,13 @@ export function createRelayServer(
}),
ready,
readinessDegradation: () => readiness.degradedDependencies(),
readinessLocal: () => options.cellFlags?.().flags.readinessLocal ?? false,
relayJwks,
localReadiness: createRelayLocalReadiness({
listening: () => server.listening,
keys: relayJwks,
failingDependencies: () => readiness.failingDependencies()
}),
recordAssignmentAdmission: (outcome) => observability.recordAssignmentAdmission?.(outcome),
recordAssignmentRejectionReason: (lane, reason) =>
observability.recordAssignmentRejectionReason?.(lane, reason),
@@ -191,6 +467,13 @@ export function createRelayServer(
observability.recordAdmissionServiceMs?.(lane, durationMs),
recordAssignmentUnavailable: (cause) => observability.recordAssignmentUnavailable?.(cause),
recordRegionRequest: (region) => observability.recordRegionRequest?.(region),
shadowSeats: shadowSeatPoller?.directory,
reservePlacement,
compareShadowSeats: shadowCompare
? (route, identity, answer) => {
shadowCompare.compare(route, identity, answer)
}
: undefined,
recordRegionSelection: (input) => observability.recordRegionSelection?.(input)
})
const observedFetch: typeof app.fetch = async (...args) => {
@@ -216,20 +499,6 @@ export function createRelayServer(
})
let preAuthConnections = 0
let totalConnections = 0
const configuredConnectionLimits =
config.connectionHardCap === undefined
? null
: (options.connectionLedgerLimits ?? {
hardCap: config.connectionHardCap,
controlReserve: RELAY_ADMISSION_BUDGETS.reservedHostControls
})
const connectionLedger =
configuredConnectionLimits === null
? null
: new RelayConnectionLedger(
configuredConnectionLimits.hardCap,
configuredConnectionLimits.controlReserve
)
const preAuthBySource = new Map<string, number>()
const preAuthAttemptsBySource = new Map<string, { windowStartedAt: number; count: number }>()
@@ -525,21 +794,28 @@ export function createRelayServer(
userId: identity.sub,
relayHostId: identity.relayHostId
})
// Null outside reserve mode, so today's path is untouched there.
const reserveAdmission = sessions.reserveAdmissionFor({
userId: identity.sub,
relayHostId: identity.relayHostId
})
// A hello costs several pooled queries, each failing after a 2 s wait. Shed
// only while the pool is already timing them out: the refused desktop gets
// the same connect error and backoff a timed-out hello gives it today, 2 s
// sooner (shipped desktops ignore Retry-After). A rebind over a live control
// is a lease rotation, not a reconnect, so it is never refused here.
if (
!isRebind &&
readRelayDatabasePoolPressure(database).databasePoolWaiting >= config.databasePoolMax &&
readRelayDatabasePoolOldestWaitMs(database) >= HOST_HELLO_SHED_OLDEST_WAIT_MS
) {
if (!isRebind && reserveAdmission === null && databaseShedding()) {
observability.recordHostHelloShed()
rejectUpgrade(socket, 503, 'Service Unavailable', HOST_HELLO_SHED_RETRY_AFTER_SECONDS)
return
}
const controlUpgrade = connectionLedger?.tryReserveControl(isRebind) ?? null
// A booked host may rise to the hard cap. Once its upgrade holds a unit, the booking's
// goes back, so the host counts once; a refused upgrade leaves the booking whole.
const controlUpgrade =
connectionLedger?.tryReserveControl(isRebind || reserveAdmission === 'booked') ?? null
if (controlUpgrade && reserveAdmission === 'booked') {
reserveBook?.handOff(identity.sub, identity.relayHostId)
}
if (
(connectionLedger && !controlUpgrade) ||
(!connectionLedger && totalConnections >= RELAY_ADMISSION_BUDGETS.cloudRunConcurrency)
@@ -547,7 +823,8 @@ export function createRelayServer(
rejectUpgrade(
socket,
connectionLedger ? 503 : 429,
connectionLedger ? 'Service Unavailable' : 'Too Many Requests'
connectionLedger ? 'Service Unavailable' : 'Too Many Requests',
connectionLedger && sessions.inReserveMode() ? CELL_FULL_RETRY_AFTER_SECONDS : undefined
)
return
}
@@ -567,7 +844,8 @@ export function createRelayServer(
webSocket,
identity,
controlUpgrade?.inclusionWatermark,
parseRelayHostCapabilities(request.headers[RELAY_HOST_CAPABILITIES_HEADER])
parseRelayHostCapabilities(request.headers[RELAY_HOST_CAPABILITIES_HEADER]),
firstHeader(request.headers[RELAY_ASSIGNMENT_LEASE_HEADER])
)
})
} catch {
@@ -616,6 +894,9 @@ export function createRelayServer(
runtimeCounts,
connectionSnapshot,
ready,
cellIncarnation
cellIncarnation,
shadowSeatPoller,
directorId,
reservePlacement
}
}

Some files were not shown because too many files have changed in this diff Show More