mirror of
https://github.com/stablyai/orca.git
synced 2026-11-02 10:08:13 +00:00
Merge remote-tracking branch 'origin/feat/mobile-remote-workspaces' into feat/mobile-remote-files-git-review
This commit is contained in:
@@ -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 }
|
||||
}
|
||||
@@ -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}"
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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/**'
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=''
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
@@ -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/
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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 }]
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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' })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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]) }
|
||||
}
|
||||
@@ -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'])(
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)} | ||||