mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
Merge origin/main into attr-child-clock
This commit is contained in:
@@ -23,10 +23,15 @@
|
||||
# runs `git apply` on one must force `-c core.autocrlf=input` rather than trust
|
||||
# the host's setting. See config/scripts/windows-process-tree-gyp-rebuild.mjs.
|
||||
/config/patches/*.patch -text
|
||||
# Same reason, and pnpm parses these too: a CRLF checkout makes the mobile
|
||||
# patches unparseable, so Windows packaging dies on ERR_PNPM_INVALID_PATCH.
|
||||
/mobile/patches/*.patch -text
|
||||
# The xterm bundle hunks also make a diff nobody can read; review the hand-written
|
||||
# source patch under xterm-src/ instead. The sibling patches stay diffable.
|
||||
/config/patches/@xterm__xterm@*.patch -diff
|
||||
/config/patches/xterm-src/*.patch text eol=lf
|
||||
# pnpm parses these unified diffs during Windows installs; keep checkout bytes stable.
|
||||
/mobile/patches/*.patch -text
|
||||
# Generated wrapper fixtures: collapse them in the PR diff so they stop drowning
|
||||
# the reviewable change, and pin LF because they are compared byte-for-byte.
|
||||
# Not -diff: the shell diff is the review surface when a wrapper does change.
|
||||
@@ -48,3 +53,41 @@
|
||||
/src/mobile-web/src/*.ts text eol=lf
|
||||
/src/mobile-web/src/*.css text eol=lf
|
||||
/src/mobile-web/src/*.png -text
|
||||
# Mobile web page source. Same buildId hazard as src/mobile-web above: these bytes are
|
||||
# hashed into the Phase C bundle, so a CRLF Windows checkout would ship a different
|
||||
# buildId for identical source. web-entry/ does not exist yet; the pin lands ahead of it.
|
||||
/mobile/src/** text eol=lf
|
||||
/mobile/app/** text eol=lf
|
||||
/mobile/web-entry/** text eol=lf
|
||||
# The blanket pin above would mark a future binary as text; exempt the asset types an
|
||||
# RN page actually carries, the same way src/mobile-web exempts its PNG.
|
||||
/mobile/src/**/*.png -text
|
||||
/mobile/src/**/*.jpg -text
|
||||
/mobile/src/**/*.jpeg -text
|
||||
/mobile/src/**/*.gif -text
|
||||
/mobile/src/**/*.ico -text
|
||||
/mobile/src/**/*.webp -text
|
||||
/mobile/src/**/*.ttf -text
|
||||
/mobile/src/**/*.otf -text
|
||||
/mobile/src/**/*.woff -text
|
||||
/mobile/src/**/*.woff2 -text
|
||||
/mobile/app/**/*.png -text
|
||||
/mobile/app/**/*.jpg -text
|
||||
/mobile/app/**/*.jpeg -text
|
||||
/mobile/app/**/*.gif -text
|
||||
/mobile/app/**/*.ico -text
|
||||
/mobile/app/**/*.webp -text
|
||||
/mobile/app/**/*.ttf -text
|
||||
/mobile/app/**/*.otf -text
|
||||
/mobile/app/**/*.woff -text
|
||||
/mobile/app/**/*.woff2 -text
|
||||
/mobile/web-entry/**/*.png -text
|
||||
/mobile/web-entry/**/*.jpg -text
|
||||
/mobile/web-entry/**/*.jpeg -text
|
||||
/mobile/web-entry/**/*.gif -text
|
||||
/mobile/web-entry/**/*.ico -text
|
||||
/mobile/web-entry/**/*.webp -text
|
||||
/mobile/web-entry/**/*.ttf -text
|
||||
/mobile/web-entry/**/*.otf -text
|
||||
/mobile/web-entry/**/*.woff -text
|
||||
/mobile/web-entry/**/*.woff2 -text
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
name: Install mobile dependencies
|
||||
description: Frozen pnpm install for the mobile/ project, whose node_modules the mobile web bundle build and the mobile-aware lint passes resolve React Native and Expo from.
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
# Why a separate install: mobile is its own pnpm project, so the root install leaves
|
||||
# mobile/node_modules empty and every mobile import resolves to nothing.
|
||||
# Why no --ignore-scripts, unlike the root install: mobile's postinstall generates the
|
||||
# gitignored terminal/mermaid webview engine modules that tracked source imports.
|
||||
# The drift guard mirrors the root install so a stale mobile lockfile fails by name --
|
||||
# mobile's lockfile carries patchedDependencies that a silent rewrite would drop.
|
||||
- name: Install mobile dependencies
|
||||
shell: bash
|
||||
working-directory: mobile
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
# Job containers can run composite steps from a source mirror without .git.
|
||||
if [ "$(git -C "$GITHUB_WORKSPACE" rev-parse --is-inside-work-tree 2>/dev/null)" = true ]; then
|
||||
git -C "$GITHUB_WORKSPACE" diff --exit-code -- \
|
||||
mobile/package.json mobile/pnpm-lock.yaml mobile/pnpm-workspace.yaml
|
||||
fi
|
||||
@@ -184,6 +184,9 @@ jobs:
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
cache-dependency-path: |
|
||||
pnpm-lock.yaml
|
||||
mobile/pnpm-lock.yaml
|
||||
|
||||
- name: Cache electron-builder downloads
|
||||
uses: actions/cache@v5
|
||||
@@ -205,6 +208,10 @@ jobs:
|
||||
retry_wait_seconds: 30
|
||||
command: pnpm install --frozen-lockfile --cpu=current,x64,arm64
|
||||
|
||||
# Why here: electron-builder's beforePack requires out/mobile-web, and the bundle
|
||||
# build resolves React Native and Expo from mobile/node_modules.
|
||||
- uses: ./.github/actions/install-mobile-dependencies
|
||||
|
||||
# Why: signing is what makes an adhoc build installable over an existing
|
||||
# Orca, so a missing cert must fail here rather than after a 20-minute build.
|
||||
- name: Verify macOS signing environment
|
||||
|
||||
@@ -78,7 +78,8 @@ jobs:
|
||||
[[ "${ROLLBACK_REHOME_PROTOCOL}" =~ ^(0|1|3)$ ]]
|
||||
[[ "${EXPECTED_SELECTOR_GENERATION}" =~ ^(0|[1-9][0-9]*)$ ]]
|
||||
[[ "${EXPECTED_REHOME_GENERATION}" =~ ^(0|[1-9][0-9]*)$ ]]
|
||||
[[ "${WAVE_INDEX}" =~ ^[0-3]$ ]]
|
||||
# cell_1..cell_10 in the calling wave; the chain is static, so this range is too.
|
||||
[[ "${WAVE_INDEX}" =~ ^[0-9]$ ]]
|
||||
# The caller validated this too; re-check here so a malformed override
|
||||
# can never reach a mutation through this reusable workflow.
|
||||
if test -n "${GATE_OVERRIDE_REASON}${GATE_OVERRIDE_CONFIRMATION}"; then
|
||||
@@ -537,9 +538,12 @@ jobs:
|
||||
|
||||
- name: Reversibly isolate and drain only the selected cell
|
||||
if: ${{ inputs.mode != 'verify' && env.ROLLBACK_RESUME != 'true' }}
|
||||
id: drain
|
||||
env:
|
||||
ORCA_RELAY_ADMIN_ID_TOKEN: ${{ steps.deploy-auth.outputs.id_token }}
|
||||
run: |
|
||||
# Opens the window the report-only shadow health gate below judges this cell over.
|
||||
echo "drain-started-at=$(date -u +%FT%TZ)" >> "${GITHUB_OUTPUT}"
|
||||
echo "MUTATION_STARTED=true" >> "${GITHUB_ENV}"
|
||||
# A cell isolated by a failed canary is already migration-only, so
|
||||
# isolate is a no-op there that does not advance the selector; the
|
||||
@@ -590,11 +594,16 @@ jobs:
|
||||
# Zero resource changes prove the prior run's apply completed and no
|
||||
# restart will follow, keeping the incarnation check honest. Root
|
||||
# outputs may lag a targeted apply, so judge resource_changes only.
|
||||
# The backend service is targeted too, so its reviewed drain timeout
|
||||
# and request logging can be the only thing left here; neither
|
||||
# restarts an instance, so the validator below clears that on its
|
||||
# own, without the template-and-MIG pair.
|
||||
terraform -chdir=infra/terraform plan \
|
||||
-var-file=environments/production.tfvars \
|
||||
-var-file="${RUNNER_TEMP}/relay-same-cap.tfvars.json" \
|
||||
"-target=google_compute_instance_template.relay_gce_cell[\"${TARGET_CELL_ID}\"]" \
|
||||
"-target=google_compute_instance_group_manager.relay_gce_cell[\"${TARGET_CELL_ID}\"]" \
|
||||
"-target=google_compute_backend_service.relay_gce_cell[\"${TARGET_CELL_ID}\"]" \
|
||||
-out="${RUNNER_TEMP}/relay-same-cap-resume.tfplan"
|
||||
if ! terraform -chdir=infra/terraform show -json \
|
||||
"${RUNNER_TEMP}/relay-same-cap-resume.tfplan" \
|
||||
@@ -617,7 +626,7 @@ jobs:
|
||||
| select(.change.actions | any(. != "no-op" and . != "read"))
|
||||
| .address] | join(","))'
|
||||
echo 'requiring reviewed rollback-image drift'
|
||||
terraform -chdir=infra/terraform show -json \
|
||||
RESUME_REVIEW="$(terraform -chdir=infra/terraform show -json \
|
||||
"${RUNNER_TEMP}/relay-same-cap-resume.tfplan" \
|
||||
| node dev/scripts/validate-relay-capacity-plan.mjs \
|
||||
--mode same-cap-cell --cell-id "${TARGET_CELL_ID}" \
|
||||
@@ -629,14 +638,30 @@ jobs:
|
||||
--rehome-director-service-account "${DIRECTOR_RUNTIME_SERVICE_ACCOUNT}" \
|
||||
--rehome-audience https://relay.onorca.dev/v1/admin/host-drain \
|
||||
--regional-rehome-protocol "${DESIRED_REHOME_PROTOCOL}" \
|
||||
"${POOL_ARGUMENTS[@]}" \
|
||||
| jq -e '.changes == 2' >/dev/null
|
||||
"${POOL_ARGUMENTS[@]}")"
|
||||
echo "${RESUME_REVIEW}"
|
||||
jq -e '.changes == 2
|
||||
or (.changes == 0 and ((.backendUpdate // []) | length) > 0)' \
|
||||
<<< "${RESUME_REVIEW}" >/dev/null
|
||||
# changes == 0 here means the template and MIG are converged and this cell's
|
||||
# reviewed backend update is the only thing left, so the resume is not complete:
|
||||
# apply it, or the cell silently keeps the 300-second drain and no request
|
||||
# logging and the operator reads that as a finished roll. The plan holds nothing
|
||||
# else (the validator bounded it to this cell's backend, and the template and MIG
|
||||
# are no-ops in it), and neither attribute restarts an instance, so the
|
||||
# incarnation check downstream stays honest. Template-and-MIG drift still applies
|
||||
# nothing, which is what a resume means.
|
||||
if test "$(jq -er '.changes' <<< "${RESUME_REVIEW}")" = 0; then
|
||||
terraform -chdir=infra/terraform apply -auto-approve \
|
||||
"${RUNNER_TEMP}/relay-same-cap-resume.tfplan"
|
||||
fi
|
||||
fi
|
||||
gcloud compute instance-groups managed wait-until "${MIG_NAME}" --stable \
|
||||
--project "${GCP_PROJECT_ID}" --zone "${TARGET_ZONE}" --timeout 900
|
||||
|
||||
- name: Apply only the selected same-cap template and MIG
|
||||
if: ${{ inputs.mode != 'verify' && env.ROLLBACK_RESUME != 'true' }}
|
||||
id: apply
|
||||
shell: bash
|
||||
env:
|
||||
CAPACITY_SERVICE_ACCOUNT: ${{ vars.PRODUCTION_GCP_RELAY_CAPACITY_SERVICE_ACCOUNT }}
|
||||
@@ -652,6 +677,7 @@ jobs:
|
||||
-var-file="${RUNNER_TEMP}/relay-same-cap.tfvars.json" \
|
||||
"-target=google_compute_instance_template.relay_gce_cell[\"${TARGET_CELL_ID}\"]" \
|
||||
"-target=google_compute_instance_group_manager.relay_gce_cell[\"${TARGET_CELL_ID}\"]" \
|
||||
"-target=google_compute_backend_service.relay_gce_cell[\"${TARGET_CELL_ID}\"]" \
|
||||
-out="${RUNNER_TEMP}/relay-same-cap.tfplan"
|
||||
PLAN_REVIEW="$(terraform -chdir=infra/terraform show -json \
|
||||
"${RUNNER_TEMP}/relay-same-cap.tfplan" \
|
||||
@@ -666,6 +692,10 @@ jobs:
|
||||
--regional-rehome-protocol "${DESIRED_REHOME_PROTOCOL}" \
|
||||
"${POOL_ARGUMENTS[@]}")"
|
||||
echo "${PLAN_REVIEW}"
|
||||
# Stamped before the apply, not after it: the new container announces its listener while
|
||||
# the MIG is still converging, so a bound taken at the end of this step is already past
|
||||
# the announcement the shadow gate looks for.
|
||||
echo "apply-started-at=$(date -u +%FT%TZ)" >> "${GITHUB_OUTPUT}"
|
||||
terraform -chdir=infra/terraform apply -auto-approve \
|
||||
"${RUNNER_TEMP}/relay-same-cap.tfplan"
|
||||
gcloud compute instance-groups managed wait-until "${MIG_NAME}" --stable \
|
||||
@@ -684,6 +714,9 @@ jobs:
|
||||
gcloud compute instance-groups managed wait-until "${MIG_NAME}" --stable \
|
||||
--project "${GCP_PROJECT_ID}" --zone "${TARGET_ZONE}" --timeout 900
|
||||
fi
|
||||
# Recorded for the operator comparing verdicts; the gate's boot search opens at the
|
||||
# apply-started-at stamp above, not here.
|
||||
echo "apply-completed-at=$(date -u +%FT%TZ)" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- id: post-auth
|
||||
if: ${{ inputs.mode != 'verify' }}
|
||||
@@ -697,6 +730,7 @@ jobs:
|
||||
|
||||
- name: Verify new incarnation, exact image, protocol, and durable safety
|
||||
if: ${{ inputs.mode != 'verify' }}
|
||||
id: verify-target
|
||||
env:
|
||||
ORCA_RELAY_ADMIN_ID_TOKEN: ${{ steps.post-auth.outputs.id_token }}
|
||||
run: |
|
||||
@@ -748,6 +782,7 @@ jobs:
|
||||
--expected-general-cells "${ISOLATED_GENERAL_CELLS}" \
|
||||
--expected-control-generation "${EXPECTED_REHOME_GENERATION}" \
|
||||
| jq -e '.control.enabled == false' >/dev/null
|
||||
echo "verify-ended-at=$(date -u +%FT%TZ)" >> "${GITHUB_OUTPUT}"
|
||||
|
||||
- name: Prove exact per-host trust and idempotent no-neighbor behavior
|
||||
if: ${{ inputs.mode != 'verify' && ((inputs.mode == 'rollback' && inputs.rollback-rehome-protocol != '0') || (inputs.mode != 'rollback' && inputs.target-rehome-protocol != '0')) }}
|
||||
@@ -794,6 +829,63 @@ jobs:
|
||||
--expected-control-generation "${EXPECTED_REHOME_GENERATION}" \
|
||||
| jq -e '.control.enabled == false' >/dev/null
|
||||
|
||||
# Report only: this evaluates the oracles an operator reads by hand after a cell and records
|
||||
# what it would have called, so its verdicts can be compared with the operator's over a full
|
||||
# roll before it is ever allowed to block. Two independent guarantees keep it inert: the
|
||||
# script exits 0 on every verdict, and continue-on-error keeps even a crash off the job's
|
||||
# outcome. The failsafe below therefore cannot fire on anything this step observes.
|
||||
#
|
||||
# It runs after the restore, not before it, for two reasons: the cell goes back into
|
||||
# admission on exactly today's schedule rather than waiting out a minute of log reads, and
|
||||
# the window it judges has closed by then, so Cloud Logging's ingestion lag is behind it.
|
||||
# These are fleet oracles anyway; when this does gate, what it gates is the next cell.
|
||||
- name: Shadow health gate (report only)
|
||||
id: shadow-gate
|
||||
if: ${{ inputs.mode != 'verify' }}
|
||||
continue-on-error: true
|
||||
# continue-on-error bounds this step's contribution to the job outcome, not its clock,
|
||||
# and its reads are serialised. A timed-out step is a failed step, which continue-on-error
|
||||
# absorbs; without this bound a Logging 429 storm or an expired credential makes every
|
||||
# read cost its full retry budget and can push the job past timeout-minutes, and a
|
||||
# cancelled job takes the whole wave with it. A healthy gate is already minutes of
|
||||
# serial reads, so both bounds sit above that: the script settles at seven minutes and
|
||||
# reaching this eight is the pathological case. Eight on top of a ~14-minute cell still
|
||||
# leaves the job's 75 minutes intact.
|
||||
timeout-minutes: 8
|
||||
env:
|
||||
DRAIN_STARTED_AT: ${{ steps.drain.outputs.drain-started-at }}
|
||||
APPLY_STARTED_AT: ${{ steps.apply.outputs.apply-started-at }}
|
||||
APPLY_COMPLETED_AT: ${{ steps.apply.outputs.apply-completed-at }}
|
||||
VERIFY_ENDED_AT: ${{ steps.verify-target.outputs.verify-ended-at }}
|
||||
SHADOW_GATE_DIRECTORY: ${{ runner.temp }}/relay-same-cap-shadow-gate
|
||||
SHADOW_GATE_NAME: relay-same-cap-shadow-gate-${{ inputs.target-cell-id }}-${{ github.run_id }}.json
|
||||
run: |
|
||||
mkdir -p "${SHADOW_GATE_DIRECTORY}"
|
||||
node dev/scripts/relay-same-cap-shadow-gate.mjs \
|
||||
--cell-id "${TARGET_CELL_ID}" \
|
||||
--cell-host "${TARGET_HOSTNAME}.relay.onorca.dev" \
|
||||
--project-id "${GCP_PROJECT_ID}" \
|
||||
--director-service orca-cloud-relay \
|
||||
--drain-started-at "${DRAIN_STARTED_AT}" \
|
||||
--apply-started-at "${APPLY_STARTED_AT}" \
|
||||
--apply-completed-at "${APPLY_COMPLETED_AT}" \
|
||||
--verify-ended-at "${VERIFY_ENDED_AT}" \
|
||||
--summary-file "${GITHUB_STEP_SUMMARY}" \
|
||||
--output-file "${SHADOW_GATE_DIRECTORY}/${SHADOW_GATE_NAME}"
|
||||
|
||||
- name: Publish the shadow health gate verdict
|
||||
if: ${{ inputs.mode != 'verify' }}
|
||||
continue-on-error: true
|
||||
# One small JSON file; a retrying upload must not spend the wave's remaining minutes either.
|
||||
timeout-minutes: 2
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: relay-same-cap-shadow-gate-${{ inputs.target-cell-id }}-${{ github.run_id }}.json
|
||||
path: ${{ runner.temp }}/relay-same-cap-shadow-gate
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
overwrite: true
|
||||
|
||||
- id: cleanup-auth
|
||||
if: ${{ failure() && inputs.mode != 'verify' }}
|
||||
uses: google-github-actions/auth@v2
|
||||
|
||||
@@ -10,7 +10,7 @@ on:
|
||||
type: choice
|
||||
options: [verify, canary-apply, batch-apply, rollback]
|
||||
cell-ids:
|
||||
description: Ordered comma-separated serving cells; one canary or two to four batch cells
|
||||
description: Ordered comma-separated serving cells; one canary or two to ten batch cells
|
||||
required: true
|
||||
type: string
|
||||
target-image-digest:
|
||||
@@ -313,6 +313,144 @@ jobs:
|
||||
wave-index: '3'
|
||||
secrets: inherit
|
||||
|
||||
cell_5:
|
||||
if: ${{ needs.cell_4.result == 'success' && fromJSON(needs.gate.outputs.cells)[4] != null }}
|
||||
needs: [gate, cell_4]
|
||||
uses: ./.github/workflows/cloud-deploy-relay-production-same-cap-job.yml
|
||||
with:
|
||||
mode: ${{ needs.gate.outputs.job-mode }}
|
||||
target-cell-id: ${{ fromJSON(needs.gate.outputs.cells)[4] }}
|
||||
target-image-digest: ${{ inputs.target-image-digest }}
|
||||
rollback-image-digest: ${{ inputs.rollback-image-digest }}
|
||||
target-rehome-protocol: ${{ inputs.target-rehome-protocol }}
|
||||
rollback-rehome-protocol: ${{ inputs.rollback-rehome-protocol }}
|
||||
expected-selector-generation: ${{ inputs.expected-selector-generation }}
|
||||
expected-existing-only-cells: ${{ inputs.expected-existing-only-cells }}
|
||||
expected-migration-only-cells: ${{ inputs.expected-migration-only-cells }}
|
||||
expected-general-cells: ${{ inputs.expected-general-cells }}
|
||||
expected-rehome-generation: ${{ inputs.expected-rehome-generation }}
|
||||
monitor-run-id: ${{ inputs.monitor-run-id }}
|
||||
monitor-run-attempt: ${{ inputs.monitor-run-attempt }}
|
||||
gate-override-reason: ${{ inputs.gate-override-reason }}
|
||||
gate-override-confirmation: ${{ inputs.gate-override-confirmation }}
|
||||
wave-index: '4'
|
||||
secrets: inherit
|
||||
|
||||
cell_6:
|
||||
if: ${{ needs.cell_5.result == 'success' && fromJSON(needs.gate.outputs.cells)[5] != null }}
|
||||
needs: [gate, cell_5]
|
||||
uses: ./.github/workflows/cloud-deploy-relay-production-same-cap-job.yml
|
||||
with:
|
||||
mode: ${{ needs.gate.outputs.job-mode }}
|
||||
target-cell-id: ${{ fromJSON(needs.gate.outputs.cells)[5] }}
|
||||
target-image-digest: ${{ inputs.target-image-digest }}
|
||||
rollback-image-digest: ${{ inputs.rollback-image-digest }}
|
||||
target-rehome-protocol: ${{ inputs.target-rehome-protocol }}
|
||||
rollback-rehome-protocol: ${{ inputs.rollback-rehome-protocol }}
|
||||
expected-selector-generation: ${{ inputs.expected-selector-generation }}
|
||||
expected-existing-only-cells: ${{ inputs.expected-existing-only-cells }}
|
||||
expected-migration-only-cells: ${{ inputs.expected-migration-only-cells }}
|
||||
expected-general-cells: ${{ inputs.expected-general-cells }}
|
||||
expected-rehome-generation: ${{ inputs.expected-rehome-generation }}
|
||||
monitor-run-id: ${{ inputs.monitor-run-id }}
|
||||
monitor-run-attempt: ${{ inputs.monitor-run-attempt }}
|
||||
gate-override-reason: ${{ inputs.gate-override-reason }}
|
||||
gate-override-confirmation: ${{ inputs.gate-override-confirmation }}
|
||||
wave-index: '5'
|
||||
secrets: inherit
|
||||
|
||||
cell_7:
|
||||
if: ${{ needs.cell_6.result == 'success' && fromJSON(needs.gate.outputs.cells)[6] != null }}
|
||||
needs: [gate, cell_6]
|
||||
uses: ./.github/workflows/cloud-deploy-relay-production-same-cap-job.yml
|
||||
with:
|
||||
mode: ${{ needs.gate.outputs.job-mode }}
|
||||
target-cell-id: ${{ fromJSON(needs.gate.outputs.cells)[6] }}
|
||||
target-image-digest: ${{ inputs.target-image-digest }}
|
||||
rollback-image-digest: ${{ inputs.rollback-image-digest }}
|
||||
target-rehome-protocol: ${{ inputs.target-rehome-protocol }}
|
||||
rollback-rehome-protocol: ${{ inputs.rollback-rehome-protocol }}
|
||||
expected-selector-generation: ${{ inputs.expected-selector-generation }}
|
||||
expected-existing-only-cells: ${{ inputs.expected-existing-only-cells }}
|
||||
expected-migration-only-cells: ${{ inputs.expected-migration-only-cells }}
|
||||
expected-general-cells: ${{ inputs.expected-general-cells }}
|
||||
expected-rehome-generation: ${{ inputs.expected-rehome-generation }}
|
||||
monitor-run-id: ${{ inputs.monitor-run-id }}
|
||||
monitor-run-attempt: ${{ inputs.monitor-run-attempt }}
|
||||
gate-override-reason: ${{ inputs.gate-override-reason }}
|
||||
gate-override-confirmation: ${{ inputs.gate-override-confirmation }}
|
||||
wave-index: '6'
|
||||
secrets: inherit
|
||||
|
||||
cell_8:
|
||||
if: ${{ needs.cell_7.result == 'success' && fromJSON(needs.gate.outputs.cells)[7] != null }}
|
||||
needs: [gate, cell_7]
|
||||
uses: ./.github/workflows/cloud-deploy-relay-production-same-cap-job.yml
|
||||
with:
|
||||
mode: ${{ needs.gate.outputs.job-mode }}
|
||||
target-cell-id: ${{ fromJSON(needs.gate.outputs.cells)[7] }}
|
||||
target-image-digest: ${{ inputs.target-image-digest }}
|
||||
rollback-image-digest: ${{ inputs.rollback-image-digest }}
|
||||
target-rehome-protocol: ${{ inputs.target-rehome-protocol }}
|
||||
rollback-rehome-protocol: ${{ inputs.rollback-rehome-protocol }}
|
||||
expected-selector-generation: ${{ inputs.expected-selector-generation }}
|
||||
expected-existing-only-cells: ${{ inputs.expected-existing-only-cells }}
|
||||
expected-migration-only-cells: ${{ inputs.expected-migration-only-cells }}
|
||||
expected-general-cells: ${{ inputs.expected-general-cells }}
|
||||
expected-rehome-generation: ${{ inputs.expected-rehome-generation }}
|
||||
monitor-run-id: ${{ inputs.monitor-run-id }}
|
||||
monitor-run-attempt: ${{ inputs.monitor-run-attempt }}
|
||||
gate-override-reason: ${{ inputs.gate-override-reason }}
|
||||
gate-override-confirmation: ${{ inputs.gate-override-confirmation }}
|
||||
wave-index: '7'
|
||||
secrets: inherit
|
||||
|
||||
cell_9:
|
||||
if: ${{ needs.cell_8.result == 'success' && fromJSON(needs.gate.outputs.cells)[8] != null }}
|
||||
needs: [gate, cell_8]
|
||||
uses: ./.github/workflows/cloud-deploy-relay-production-same-cap-job.yml
|
||||
with:
|
||||
mode: ${{ needs.gate.outputs.job-mode }}
|
||||
target-cell-id: ${{ fromJSON(needs.gate.outputs.cells)[8] }}
|
||||
target-image-digest: ${{ inputs.target-image-digest }}
|
||||
rollback-image-digest: ${{ inputs.rollback-image-digest }}
|
||||
target-rehome-protocol: ${{ inputs.target-rehome-protocol }}
|
||||
rollback-rehome-protocol: ${{ inputs.rollback-rehome-protocol }}
|
||||
expected-selector-generation: ${{ inputs.expected-selector-generation }}
|
||||
expected-existing-only-cells: ${{ inputs.expected-existing-only-cells }}
|
||||
expected-migration-only-cells: ${{ inputs.expected-migration-only-cells }}
|
||||
expected-general-cells: ${{ inputs.expected-general-cells }}
|
||||
expected-rehome-generation: ${{ inputs.expected-rehome-generation }}
|
||||
monitor-run-id: ${{ inputs.monitor-run-id }}
|
||||
monitor-run-attempt: ${{ inputs.monitor-run-attempt }}
|
||||
gate-override-reason: ${{ inputs.gate-override-reason }}
|
||||
gate-override-confirmation: ${{ inputs.gate-override-confirmation }}
|
||||
wave-index: '8'
|
||||
secrets: inherit
|
||||
|
||||
cell_10:
|
||||
if: ${{ needs.cell_9.result == 'success' && fromJSON(needs.gate.outputs.cells)[9] != null }}
|
||||
needs: [gate, cell_9]
|
||||
uses: ./.github/workflows/cloud-deploy-relay-production-same-cap-job.yml
|
||||
with:
|
||||
mode: ${{ needs.gate.outputs.job-mode }}
|
||||
target-cell-id: ${{ fromJSON(needs.gate.outputs.cells)[9] }}
|
||||
target-image-digest: ${{ inputs.target-image-digest }}
|
||||
rollback-image-digest: ${{ inputs.rollback-image-digest }}
|
||||
target-rehome-protocol: ${{ inputs.target-rehome-protocol }}
|
||||
rollback-rehome-protocol: ${{ inputs.rollback-rehome-protocol }}
|
||||
expected-selector-generation: ${{ inputs.expected-selector-generation }}
|
||||
expected-existing-only-cells: ${{ inputs.expected-existing-only-cells }}
|
||||
expected-migration-only-cells: ${{ inputs.expected-migration-only-cells }}
|
||||
expected-general-cells: ${{ inputs.expected-general-cells }}
|
||||
expected-rehome-generation: ${{ inputs.expected-rehome-generation }}
|
||||
monitor-run-id: ${{ inputs.monitor-run-id }}
|
||||
monitor-run-attempt: ${{ inputs.monitor-run-attempt }}
|
||||
gate-override-reason: ${{ inputs.gate-override-reason }}
|
||||
gate-override-confirmation: ${{ inputs.gate-override-confirmation }}
|
||||
wave-index: '9'
|
||||
secrets: inherit
|
||||
|
||||
seal_canary:
|
||||
if: ${{ inputs.mode == 'canary-apply' }}
|
||||
needs: [gate, cell_1]
|
||||
@@ -360,6 +498,12 @@ jobs:
|
||||
- cell_2
|
||||
- cell_3
|
||||
- cell_4
|
||||
- cell_5
|
||||
- cell_6
|
||||
- cell_7
|
||||
- cell_8
|
||||
- cell_9
|
||||
- cell_10
|
||||
- seal_canary
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2204
|
||||
timeout-minutes: 10
|
||||
|
||||
@@ -320,7 +320,7 @@ jobs:
|
||||
echo '### Regional rehome control'
|
||||
jq -r '"- mode: `\(.mode)`\n- generation: `\(.control.generation)`\n- enabled: `\(.control.enabled)`"' \
|
||||
"${RUNNER_TEMP}/relay-rehome-control.json"
|
||||
jq -r '"- active: `\(.active)`\n- awaiting receipt: `\(.awaitingReceipt)`\n- target registered: `\(.targetRegistered)`\n- completed (24h): `\(.completedLast24Hours)`\n- aborted (24h): `\(.abortedLast24Hours)`"' \
|
||||
jq -r '"- active: `\(.active)`\n- awaiting receipt: `\(.awaitingReceipt)`\n- target registered: `\(.targetRegistered)`\n- completed (24h): `\(.completedLast24Hours)`\n- aborted (24h): `\(.abortedLast24Hours)`\n- host not arrived (24h): `\(.hostNotArrivedLast24Hours // "not reported")`\n- oldest active age (ms): `\(.oldestActiveAgeMs // "none")`"' \
|
||||
"${RUNNER_TEMP}/relay-rehome-inventory.json"
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
|
||||
@@ -57,7 +57,28 @@ jobs:
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: dist/win-unpacked
|
||||
key: win-unpacked-${{ hashFiles('src/**', 'config/**', 'package.json', 'pnpm-lock.yaml') }}
|
||||
# mobile/ is in the key because beforePack requires out/mobile-web, whose bytes come from
|
||||
# the mobile install and, once Phase C flips the bundle, from the page trees below; a
|
||||
# mobile-only change must miss this cache, not reuse a stale installer. src/** and
|
||||
# config/** already cover src/mobile-web and the two bundle builders.
|
||||
key: >-
|
||||
win-unpacked-${{ hashFiles(
|
||||
'src/**',
|
||||
'config/**',
|
||||
'package.json',
|
||||
'pnpm-lock.yaml',
|
||||
'mobile/package.json',
|
||||
'mobile/pnpm-lock.yaml',
|
||||
'mobile/app/**',
|
||||
'mobile/src/**',
|
||||
'mobile/web-entry/**'
|
||||
) }}
|
||||
|
||||
# Why here: electron-builder's beforePack requires out/mobile-web, and the bundle
|
||||
# build resolves React Native and Expo from mobile/node_modules. Gated with the
|
||||
# build it feeds, so a cache hit does not pay for an install nothing consumes.
|
||||
- uses: ./.github/actions/install-mobile-dependencies
|
||||
if: steps.cache-unpacked.outputs.cache-hit != 'true'
|
||||
|
||||
- name: Build unpacked app
|
||||
if: steps.cache-unpacked.outputs.cache-hit != 'true'
|
||||
|
||||
@@ -156,6 +156,9 @@ jobs:
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
cache-dependency-path: |
|
||||
pnpm-lock.yaml
|
||||
mobile/pnpm-lock.yaml
|
||||
|
||||
- name: Cache electron-builder downloads
|
||||
if: steps.freshness.outputs.should_build == 'true'
|
||||
@@ -179,6 +182,11 @@ jobs:
|
||||
retry_wait_seconds: 30
|
||||
command: pnpm install --frozen-lockfile --cpu=current,x64,arm64
|
||||
|
||||
# Why here: electron-builder's beforePack requires out/mobile-web, and the bundle
|
||||
# build resolves React Native and Expo from mobile/node_modules.
|
||||
- uses: ./.github/actions/install-mobile-dependencies
|
||||
if: steps.freshness.outputs.should_build == 'true'
|
||||
|
||||
# Why: signing is what makes a daily installable over an existing Orca, so
|
||||
# a missing cert must fail here rather than after a 20-minute build.
|
||||
- name: Verify macOS signing environment
|
||||
|
||||
@@ -203,6 +203,9 @@ jobs:
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
cache-dependency-path: |
|
||||
pnpm-lock.yaml
|
||||
mobile/pnpm-lock.yaml
|
||||
|
||||
# Caches the Electron binary and electron-builder's tool downloads (nsis,
|
||||
# winCodeSign). Same key shape as release-cut's Windows leg.
|
||||
@@ -229,6 +232,10 @@ jobs:
|
||||
retry_wait_seconds: 30
|
||||
command: pnpm install --frozen-lockfile
|
||||
|
||||
# Why here: electron-builder's beforePack requires out/mobile-web, and the bundle
|
||||
# build resolves React Native and Expo from mobile/node_modules.
|
||||
- uses: ./.github/actions/install-mobile-dependencies
|
||||
|
||||
# Why the packaging check runs before the 20-minute build: it only needs
|
||||
# node_modules, and a stale config should cost seconds rather than a build.
|
||||
- name: Verify dev-channel packaging identity
|
||||
|
||||
@@ -164,6 +164,9 @@ jobs:
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
cache-dependency-path: |
|
||||
pnpm-lock.yaml
|
||||
mobile/pnpm-lock.yaml
|
||||
|
||||
- name: Cache electron-builder downloads
|
||||
uses: actions/cache@v5
|
||||
@@ -185,6 +188,10 @@ jobs:
|
||||
retry_wait_seconds: 30
|
||||
command: pnpm install --frozen-lockfile --cpu=current,x64,arm64
|
||||
|
||||
# Why here: electron-builder's beforePack requires out/mobile-web, and the bundle
|
||||
# build resolves React Native and Expo from mobile/node_modules.
|
||||
- uses: ./.github/actions/install-mobile-dependencies
|
||||
|
||||
# Why: signing is what makes an hourly installable over an existing Orca, so
|
||||
# a missing cert must fail here rather than after a 20-minute build.
|
||||
- name: Verify macOS signing environment
|
||||
|
||||
@@ -103,6 +103,12 @@ jobs:
|
||||
- name: Typecheck tests (ratchet)
|
||||
run: pnpm run check:tests-typecheck
|
||||
|
||||
# This includes the bridged replay of the whole recording corpus, which used to be a second
|
||||
# step of its own behind RPC_FOUNDATION_BRIDGE=1. A gate nobody can forget to set is the point:
|
||||
# it fails when a divergence class grows, when a divergence lands in no class at all, or when
|
||||
# one of the 103 goldens inside the C1 page closure changes the verdict it is pinned to. It is
|
||||
# ~3 min of test time on its own, and Vitest runs it on a worker beside the rest of the suite,
|
||||
# so folding it in costs a fraction of that in wall time and one step less to skip.
|
||||
- name: Test
|
||||
run: pnpm test
|
||||
|
||||
|
||||
@@ -2,8 +2,9 @@ name: Pi owner runtime verification
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'src/main/pi/agent-status-handler-source.ts'
|
||||
- 'src/main/pi/**'
|
||||
- 'tests/tools/pi-owner-runtime-smoke.mjs'
|
||||
- 'tests/tools/omp-completion-runtime-smoke.mjs'
|
||||
- '.github/workflows/pi-owner-runtime.yml'
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
@@ -27,3 +28,5 @@ jobs:
|
||||
run: npm install --prefix .cache/pi-owner --ignore-scripts --no-audit --no-fund @earendil-works/pi-coding-agent@0.83.0
|
||||
- name: Verify real owner exit and hook delivery
|
||||
run: node tests/tools/pi-owner-runtime-smoke.mjs .cache/pi-owner/node_modules/@earendil-works/pi-coding-agent
|
||||
- name: Verify OMP completion over native HTTP
|
||||
run: node tests/tools/omp-completion-runtime-smoke.mjs
|
||||
|
||||
+98
-17
@@ -29,6 +29,7 @@ jobs:
|
||||
should_run: ${{ steps.filter.outputs.should_run }}
|
||||
native_cache_changed: ${{ steps.filter.outputs.native_cache_changed }}
|
||||
mobile_dependencies: ${{ steps.filter.outputs.mobile_dependencies }}
|
||||
mobile_web_app: ${{ steps.filter.outputs.mobile_web_app }}
|
||||
static_analysis: ${{ steps.filter.outputs.static_analysis }}
|
||||
typecheck: ${{ steps.filter.outputs.typecheck }}
|
||||
git_compatibility: ${{ steps.filter.outputs.git_compatibility }}
|
||||
@@ -142,24 +143,11 @@ jobs:
|
||||
- name: Enforce type-aware code-quality baseline
|
||||
run: pnpm run audit:code-quality:type-aware
|
||||
|
||||
# Why: the changed-code gate lints mobile files too, and its type-aware pass
|
||||
# resolves types from mobile/node_modules. Mobile is a separate pnpm project,
|
||||
# so the root install above leaves it empty and every mobile type degrades to
|
||||
# an `error` type — reported as phantom findings against the changed lines.
|
||||
# Why no --ignore-scripts, unlike the root install: mobile's postinstall generates
|
||||
# the gitignored terminal/mermaid webview engine modules that tracked source imports,
|
||||
# and skipping it degrades those very types the step exists to resolve. The drift
|
||||
# guard mirrors the root install so a stale mobile lockfile fails by name — mobile's
|
||||
# lockfile carries patchedDependencies that a silent rewrite would drop.
|
||||
- name: Install mobile dependencies
|
||||
# Why here: the changed-code gate lints mobile files too, and its type-aware pass
|
||||
# resolves types from mobile/node_modules. Without the install every mobile type
|
||||
# degrades to an `error` type — reported as phantom findings against the changed lines.
|
||||
- uses: ./.github/actions/install-mobile-dependencies
|
||||
if: needs.code_paths.outputs.mobile_dependencies == 'true'
|
||||
working-directory: mobile
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
if [ "$(git -C "$GITHUB_WORKSPACE" rev-parse --is-inside-work-tree 2>/dev/null)" = true ]; then
|
||||
git -C "$GITHUB_WORKSPACE" diff --exit-code -- \
|
||||
mobile/package.json mobile/pnpm-lock.yaml mobile/pnpm-workspace.yaml
|
||||
fi
|
||||
|
||||
- name: Enforce changed-code quality
|
||||
run: pnpm run check:code-quality:changed -- "${{ github.event.pull_request.base.sha }}"
|
||||
@@ -661,6 +649,79 @@ jobs:
|
||||
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. Dark through Phase C: this proves
|
||||
# `build:mobile-web:app` on every PR that touches the page, and ships nothing -- packaging still
|
||||
# builds the Phase A bootstrap via build:mobile-web.
|
||||
mobile_web_app:
|
||||
name: mobile web app bundle
|
||||
needs: [code_paths]
|
||||
if: needs.code_paths.outputs.mobile_web_app == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# Why no native-runtime: the builder is esbuild and the render check is a browser. Nothing
|
||||
# in this job loads node-pty.
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
native-runtime: node
|
||||
cache-dependency-path: |
|
||||
pnpm-lock.yaml
|
||||
mobile/pnpm-lock.yaml
|
||||
|
||||
# The entry lives in mobile/ so one React resolves; without this every RN import is nothing.
|
||||
- uses: ./.github/actions/install-mobile-dependencies
|
||||
|
||||
# Why the runner's Google Chrome and not a downloaded chromium: same reason as the orcad
|
||||
# browser job -- Ubuntu 24.04 only ships an AppArmor userns profile for the Chrome .deb.
|
||||
# Why fail instead of skip: a silently skipped render check is the failure this job exists
|
||||
# to prevent.
|
||||
- name: Resolve Chrome for the render check
|
||||
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 render check would silently skip."
|
||||
exit 1
|
||||
fi
|
||||
"$chrome" --version
|
||||
echo "ORCA_MOBILE_WEB_RENDER_BROWSER=$chrome" >> "$GITHUB_ENV"
|
||||
|
||||
# The drawer check runs on WebKit as well as Chrome, because the shell's iOS WebView is
|
||||
# WebKit and the Chrome above cannot stand in for it. Downloaded rather than resolved from
|
||||
# the runner: Ubuntu ships no WebKit build to point at.
|
||||
- name: Install WebKit for the drawer check
|
||||
run: pnpm exec playwright install --with-deps webkit
|
||||
|
||||
- name: Build and verify the app bundle
|
||||
run: pnpm run build:mobile-web:app
|
||||
|
||||
# The bundling tests skip themselves where mobile dependencies are absent, which is how they
|
||||
# stay green in the sharded `test` job. This is the job that installs them, so here a missing
|
||||
# install has to fail rather than skip everything the job exists to run.
|
||||
# Why a prefix and not a file list: the list this replaces had gone stale twice without
|
||||
# anyone noticing, because a census whose closure block skips without the env flag below is
|
||||
# green in the sharded `test` job whether or not it ever runs here. The prefix is the same
|
||||
# one `pr-code-change-scope.mjs` fires this job on, so naming a test into the family is all
|
||||
# it takes to have it run. Quoted because these are vitest filename filters, matched as
|
||||
# substrings against the discovered files, and the shell must not touch them.
|
||||
#
|
||||
# Cost: 18 files in 25-30s wall, of which the frame-budget sweep is 2.5s. That sweep encodes
|
||||
# 111 noise JPEGs in Chromium, so it is the one step here whose cost grows with its viewport
|
||||
# set; adding rows to that set is a decision about this job's runtime.
|
||||
- name: Builder, override census and render checks
|
||||
env:
|
||||
ORCA_MOBILE_WEB_APP_DEPS_REQUIRED: '1'
|
||||
run: |
|
||||
pnpm exec vitest run --config config/vitest.config.ts \
|
||||
'config/scripts/mobile-web-app-' \
|
||||
'config/scripts/build-mobile-web-app-bundle.test.mjs'
|
||||
|
||||
cross-version-wire:
|
||||
name: cross-version wire compatibility
|
||||
needs: [code_paths]
|
||||
@@ -695,6 +756,8 @@ jobs:
|
||||
tests/e2e/cross-version-wire/cross-version-terminal-wire.unit.test.ts
|
||||
tests/e2e/cross-version-wire/reported-lossy-initial-snapshot.unit.test.ts
|
||||
tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts
|
||||
tests/e2e/cross-version-wire/cross-version-worktree-identity-downgrade.unit.test.ts
|
||||
tests/e2e/cross-version-wire/cross-version-session-tabs-retirement-proof.unit.test.ts
|
||||
|
||||
managed_hook_node18:
|
||||
name: managed hooks on Node 18
|
||||
@@ -746,6 +809,13 @@ jobs:
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
native-runtime: electron
|
||||
cache-dependency-path: |
|
||||
pnpm-lock.yaml
|
||||
mobile/pnpm-lock.yaml
|
||||
|
||||
# Why here: electron-builder's beforePack requires out/mobile-web, and the bundle
|
||||
# build resolves React Native and Expo from mobile/node_modules.
|
||||
- uses: ./.github/actions/install-mobile-dependencies
|
||||
|
||||
# Why --no-file-parallelism: every file here launches a full Electron stack twice, and each
|
||||
# probe carries its own in-process deadline. Four at once on a 4-vCPU runner starve each other
|
||||
@@ -859,6 +929,13 @@ jobs:
|
||||
with:
|
||||
native-runtime: node
|
||||
persist-native-cache: 'false'
|
||||
cache-dependency-path: |
|
||||
pnpm-lock.yaml
|
||||
mobile/pnpm-lock.yaml
|
||||
|
||||
# Why here: electron-builder's beforePack requires out/mobile-web, and the bundle
|
||||
# build resolves React Native and Expo from mobile/node_modules.
|
||||
- uses: ./.github/actions/install-mobile-dependencies
|
||||
|
||||
- name: Save compiled Node native modules
|
||||
if: steps.deps.outputs.native-cache-hit != 'true'
|
||||
@@ -1017,6 +1094,7 @@ jobs:
|
||||
- shell_contracts
|
||||
- test
|
||||
- orcad_browser
|
||||
- mobile_web_app
|
||||
- cross-version-wire
|
||||
- managed_hook_node18
|
||||
- package
|
||||
@@ -1053,6 +1131,8 @@ jobs:
|
||||
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 }}
|
||||
CROSS_VERSION_WIRE_SHOULD_RUN: ${{ needs.code_paths.outputs.cross-version-wire }}
|
||||
MANAGED_HOOK_NODE18: ${{ needs.managed_hook_node18.result }}
|
||||
@@ -1095,6 +1175,7 @@ jobs:
|
||||
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"
|
||||
check_job package "$PACKAGE" "$PACKAGE_SHOULD_RUN"
|
||||
|
||||
@@ -107,6 +107,10 @@ jobs:
|
||||
with:
|
||||
ref: ${{ github.event_name == 'schedule' && 'main' || inputs.ref }}
|
||||
fetch-depth: 0
|
||||
# Why: version math recovers unpublished tags; checkout's default
|
||||
# fetch-tags:false hides them, so a patch cut recreates vX.Y.Z and
|
||||
# `git push` overwrites the existing tag.
|
||||
fetch-tags: true
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
@@ -805,6 +809,16 @@ jobs:
|
||||
with:
|
||||
ref: refs/tags/${{ needs.cut.outputs.tag }}
|
||||
|
||||
- name: Restore draft-release scripts from the workflow ref
|
||||
env:
|
||||
WORKFLOW_SHA: ${{ github.workflow_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA"
|
||||
git checkout "$WORKFLOW_SHA" -- \
|
||||
config/scripts/create-draft-release.mjs \
|
||||
config/scripts/assert-github-release-is-draft.mjs
|
||||
|
||||
- name: Create draft release with bounded generated notes
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -846,7 +860,8 @@ jobs:
|
||||
git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA"
|
||||
git checkout "$WORKFLOW_SHA" -- \
|
||||
tests/e2e/golden-source-control-open-diff.spec.ts \
|
||||
tests/e2e/golden-terminal-file-link.spec.ts
|
||||
tests/e2e/golden-terminal-file-link.spec.ts \
|
||||
tests/e2e/golden-worktree-create-switch.spec.ts
|
||||
|
||||
- name: Install native build tools
|
||||
if: runner.os == 'Linux'
|
||||
@@ -871,8 +886,17 @@ jobs:
|
||||
npm install -g node-gyp@11.5.0
|
||||
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
|
||||
|
||||
# Why: this install runs lifecycle scripts, so node-gyp rebuilds
|
||||
# native/windows-registry and fetches that Node version's headers from
|
||||
# nodejs.org. One `read ECONNRESET` there failed this blocking gate and the
|
||||
# whole cut. Retry like the release build's install below.
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
uses: nick-fields/retry@v4
|
||||
with:
|
||||
timeout_minutes: 10
|
||||
max_attempts: 3
|
||||
retry_wait_seconds: 30
|
||||
command: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build Electron app for platform golden
|
||||
run: npx electron-vite build --mode e2e
|
||||
@@ -1088,8 +1112,14 @@ jobs:
|
||||
npm install -g node-gyp@11.5.0
|
||||
echo "npm_config_node_gyp=$(npm root -g)/node-gyp/bin/node-gyp.js" >> "$GITHUB_ENV"
|
||||
|
||||
# Same node-gyp header fetch as the blocking golden gate above.
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
uses: nick-fields/retry@v4
|
||||
with:
|
||||
timeout_minutes: 10
|
||||
max_attempts: 3
|
||||
retry_wait_seconds: 30
|
||||
command: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build Electron app for terminal rendering evidence
|
||||
run: npx electron-vite build --mode e2e
|
||||
@@ -1161,14 +1191,14 @@ jobs:
|
||||
~\AppData\Local\electron-builder\Cache
|
||||
- os: ubuntu-latest
|
||||
platform: linux-x64
|
||||
release_command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && pnpm exec electron-builder --config config/electron-builder.config.cjs --linux AppImage deb rpm --x64 --publish always
|
||||
release_command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && pnpm exec electron-builder --config config/electron-builder.config.cjs --linux AppImage deb rpm --x64 --publish always -c.publish.releaseType=draft
|
||||
unpacked_dir: dist/linux-unpacked
|
||||
eb_cache_path: |
|
||||
~/.cache/electron
|
||||
~/.cache/electron-builder
|
||||
- os: ubuntu-24.04-arm
|
||||
platform: linux-arm64
|
||||
release_command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && ORCA_LINUX_ARM64_RELEASE=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --linux AppImage deb rpm --arm64 --publish always
|
||||
release_command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && ORCA_LINUX_ARM64_RELEASE=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --linux AppImage deb rpm --arm64 --publish always -c.publish.releaseType=draft
|
||||
unpacked_dir: dist/linux-arm64-unpacked
|
||||
eb_cache_path: |
|
||||
~/.cache/electron
|
||||
@@ -1204,22 +1234,45 @@ jobs:
|
||||
# ref, so cutting from an older/off-main ref whose tree predates a composite
|
||||
# action would fail the step with "Can't find 'action.yml'". Restore the
|
||||
# actions directory from the commit this workflow file itself came from.
|
||||
- name: Restore composite actions from the workflow ref
|
||||
if: matrix.platform == 'win' && github.run_attempt == 1
|
||||
# Not Windows-only: every platform now consumes install-mobile-dependencies, so
|
||||
# any of them can be the one whose cut ref predates the action.
|
||||
- name: Restore draft-publish scripts from the workflow ref
|
||||
# Why: this job checks out the release tag, so a cut from an older SHA
|
||||
# still has electron-builder releaseType:release and no re-draft helper.
|
||||
# The workflow YAML is from main; restore the scripts it invokes.
|
||||
shell: bash
|
||||
env:
|
||||
WORKFLOW_SHA: ${{ github.workflow_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
action_path=".github/actions/install-signpath-module/action.yml"
|
||||
if [ -f "$action_path" ]; then
|
||||
git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA"
|
||||
git checkout "$WORKFLOW_SHA" -- config/scripts/assert-github-release-is-draft.mjs
|
||||
|
||||
- name: Restore composite actions from the workflow ref
|
||||
shell: bash
|
||||
env:
|
||||
WORKFLOW_SHA: ${{ github.workflow_sha }}
|
||||
PLATFORM: ${{ matrix.platform }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
required=(.github/actions/install-mobile-dependencies/action.yml)
|
||||
if [ "$PLATFORM" = win ] && [ "$GITHUB_RUN_ATTEMPT" = 1 ]; then
|
||||
required+=(.github/actions/install-signpath-module/action.yml)
|
||||
fi
|
||||
missing=()
|
||||
for action_path in "${required[@]}"; do
|
||||
[ -f "$action_path" ] || missing+=("$action_path")
|
||||
done
|
||||
if [ "${#missing[@]}" -eq 0 ]; then
|
||||
echo "Composite actions already present at the cut ref."
|
||||
exit 0
|
||||
fi
|
||||
echo "Cut ref predates $action_path; restoring it from $WORKFLOW_SHA."
|
||||
echo "Cut ref predates ${missing[*]}; restoring from $WORKFLOW_SHA."
|
||||
git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA"
|
||||
git checkout "$WORKFLOW_SHA" -- .github/actions
|
||||
test -f "$action_path"
|
||||
for action_path in "${required[@]}"; do
|
||||
test -f "$action_path"
|
||||
done
|
||||
|
||||
# pnpm must be on PATH before setup-node so setup-node can locate the store for caching.
|
||||
- name: Setup pnpm
|
||||
@@ -1232,6 +1285,9 @@ jobs:
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
cache-dependency-path: |
|
||||
pnpm-lock.yaml
|
||||
mobile/pnpm-lock.yaml
|
||||
|
||||
# Why: release builds hit the same native-module postinstall path as
|
||||
# PR CI, so keep the pinned node-gyp override here too instead of
|
||||
@@ -1272,6 +1328,10 @@ jobs:
|
||||
retry_wait_seconds: 30
|
||||
command: pnpm install --frozen-lockfile
|
||||
|
||||
# Why here: electron-builder's beforePack requires out/mobile-web, and the bundle
|
||||
# build resolves React Native and Expo from mobile/node_modules.
|
||||
- uses: ./.github/actions/install-mobile-dependencies
|
||||
|
||||
# Why: `pnpm build:release` verifies the Linux computer-use provider by
|
||||
# importing AT-SPI bindings, which are runtime package deps but are not
|
||||
# present on stock GitHub Ubuntu release runners.
|
||||
@@ -2160,36 +2220,21 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Verify release remains draft after artifact upload
|
||||
# Why: the build matrix must never be the actor that exposes a partial
|
||||
# release. If an uploader or GitHub transition flips draft early, fail
|
||||
# this platform leg and leave the diagnostic monitor artifact behind.
|
||||
# Why: electron-builder `--publish always` can create a public release
|
||||
# as soon as this platform uploads. Re-draft immediately, then fail, so
|
||||
# /releases/latest never keeps serving a missing Windows exe.
|
||||
# Why bash: the Windows matrix defaults to pwsh, which does not expand
|
||||
# "$TAG" into argv.
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ needs.cut.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
releases_json="$(gh api "repos/$GITHUB_REPOSITORY/releases?per_page=100")"
|
||||
# Why: release upload must validate the draft before it is publicly visible.
|
||||
draft="$(jq -e -r --arg tag "$TAG" '
|
||||
map(select(.tag_name == $tag))
|
||||
| if length == 1 and (.[0].draft | type) == "boolean" then (.[0].draft | tostring) else empty end
|
||||
' <<<"$releases_json")" || {
|
||||
echo "::error::Release $TAG was not found in the draft-aware releases list, or its draft state was missing."
|
||||
exit 1
|
||||
}
|
||||
if [[ "$draft" != "true" ]]; then
|
||||
echo "::error::Release $TAG was published during the ${{ matrix.platform }} artifact upload."
|
||||
exit 1
|
||||
fi
|
||||
run: node config/scripts/assert-github-release-is-draft.mjs "${{ needs.cut.outputs.tag }}"
|
||||
|
||||
# Why post-publish for Linux: electron-builder packs and uploads in a
|
||||
# single `--publish always` invocation, so there is no cheap insertion
|
||||
# point between pack and upload without splitting those steps. Running
|
||||
# verify last still blocks the bad release: the binary is uploaded to the
|
||||
# draft, but a failed matrix job blocks `publish-release` from flipping
|
||||
# the release from draft → published, so users never see it. A human then
|
||||
# deletes the draft and re-cuts.
|
||||
# Why post-pack for Linux: electron-builder packs and uploads in one
|
||||
# `--publish always` invocation. The previous step re-drafts if that
|
||||
# upload flipped the GitHub release public; this telemetry check still
|
||||
# blocks `publish-release` from undrafting a bad binary.
|
||||
#
|
||||
# Why this guards against: a misconfigured CI run where
|
||||
# `ORCA_POSTHOG_WRITE_KEY` is unset or the tag fails to classify
|
||||
|
||||
@@ -37,6 +37,14 @@ jobs:
|
||||
with:
|
||||
ref: refs/tags/${{ inputs.tag }}
|
||||
|
||||
- name: Restore draft-publish scripts from the workflow ref
|
||||
env:
|
||||
WORKFLOW_SHA: ${{ github.workflow_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch --no-tags --depth=1 origin "$WORKFLOW_SHA"
|
||||
git checkout "$WORKFLOW_SHA" -- config/scripts/assert-github-release-is-draft.mjs
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/setup@v2
|
||||
with:
|
||||
@@ -47,6 +55,9 @@ jobs:
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
cache-dependency-path: |
|
||||
pnpm-lock.yaml
|
||||
mobile/pnpm-lock.yaml
|
||||
|
||||
# Cache the Electron binary + electron-builder tool downloads (notarytool,
|
||||
# winCodeSign, nsis, squirrel, AppImage). Saves ~30-90s per job, incl. mac.
|
||||
@@ -74,6 +85,10 @@ jobs:
|
||||
retry_wait_seconds: 30
|
||||
command: pnpm install --frozen-lockfile --cpu=current,x64,arm64
|
||||
|
||||
# Why here: electron-builder's beforePack requires out/mobile-web, and the bundle
|
||||
# build resolves React Native and Expo from mobile/node_modules.
|
||||
- uses: ./.github/actions/install-mobile-dependencies
|
||||
|
||||
- name: Verify macOS signing environment
|
||||
run: node config/scripts/verify-macos-release-env.mjs
|
||||
env:
|
||||
@@ -135,13 +150,28 @@ jobs:
|
||||
# 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
|
||||
# public with only a partial mac upload.
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PARENT_RUN: ${{ inputs.release_run_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
conclusion="$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/$PARENT_RUN" --jq '.conclusion // empty')"
|
||||
if [[ "$conclusion" == "cancelled" || "$conclusion" == "failure" || "$conclusion" == "timed_out" ]]; then
|
||||
echo "::error::Parent release-cut run $PARENT_RUN is $conclusion; refusing to publish mac artifacts."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Publish release artifacts (macOS)
|
||||
uses: nick-fields/retry@v4
|
||||
with:
|
||||
timeout_minutes: 45
|
||||
max_attempts: 3
|
||||
retry_wait_seconds: 30
|
||||
command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && ORCA_MAC_RELEASE=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --mac --publish always
|
||||
command: node config/scripts/ensure-native-runtime.mjs --runtime=electron && ORCA_MAC_RELEASE=1 pnpm exec electron-builder --config config/electron-builder.config.cjs --mac --publish always -c.publish.releaseType=draft
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CSC_LINK: ${{ secrets.MAC_CERTS }}
|
||||
@@ -151,28 +181,14 @@ jobs:
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
|
||||
- name: Verify release remains draft after artifact upload
|
||||
# Why: the macOS build must never be the actor that exposes a partial
|
||||
# release. If an uploader or GitHub transition flips draft early, fail
|
||||
# this job so release-cut never publishes the release.
|
||||
# Why: re-draft immediately if electron-builder flipped the GitHub
|
||||
# release public, then fail. Checking without restoring leaves
|
||||
# /releases/latest serving a missing Windows exe.
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
releases_json="$(gh api "repos/$GITHUB_REPOSITORY/releases?per_page=100")"
|
||||
# Why: release upload must validate the draft before it is publicly visible.
|
||||
draft="$(jq -e -r --arg tag "$TAG" '
|
||||
map(select(.tag_name == $tag))
|
||||
| if length == 1 and (.[0].draft | type) == "boolean" then (.[0].draft | tostring) else empty end
|
||||
' <<<"$releases_json")" || {
|
||||
echo "::error::Release $TAG was not found in the draft-aware releases list, or its draft state was missing."
|
||||
exit 1
|
||||
}
|
||||
if [[ "$draft" != "true" ]]; then
|
||||
echo "::error::Release $TAG was published during the mac artifact upload."
|
||||
exit 1
|
||||
fi
|
||||
run: node config/scripts/assert-github-release-is-draft.mjs "${{ inputs.tag }}"
|
||||
|
||||
# Why post-publish for macOS: electron-builder packs and uploads in a
|
||||
# single `--publish always` invocation, so there is no cheap insertion
|
||||
|
||||
@@ -33,17 +33,13 @@ jobs:
|
||||
native-runtime: node
|
||||
node-version: ${{ matrix.node }}
|
||||
cache-electron-package: 'true'
|
||||
cache-dependency-path: |
|
||||
pnpm-lock.yaml
|
||||
cloud/pnpm-lock.yaml
|
||||
|
||||
- name: Install Electron package binary for tests
|
||||
run: node config/scripts/install-electron-package-binary.mjs
|
||||
|
||||
# The real two-cell transport test imports cloud relay source and its contracts.
|
||||
- name: Install relay integration dependencies
|
||||
working-directory: cloud
|
||||
run: |
|
||||
npx --yes pnpm@10.24.0 --filter '@orca-cloud/relay...' install --frozen-lockfile --ignore-scripts
|
||||
npx --yes pnpm@10.24.0 --filter '@orca-cloud/relay^...' build
|
||||
|
||||
- name: Test shard
|
||||
env:
|
||||
ORCA_BALANCE_UNIT_SHARDS: '1'
|
||||
@@ -68,6 +64,8 @@ jobs:
|
||||
--exclude=src/shared/pty-reply-echo-shapes.node-pty.test.ts \
|
||||
--exclude=src/shared/startup-shell-portability.live-shell.test.ts \
|
||||
--exclude=src/shared/posix-command-path-lookup.test.ts \
|
||||
--exclude=tests/e2e/relay-region-compatibility.unit.test.ts \
|
||||
--exclude=tests/e2e/relay-region-correction.unit.test.ts \
|
||||
--exclude=tests/e2e/cross-version-wire/** \
|
||||
--shard=${{ matrix.shard }}/${{ matrix.shard_total }}
|
||||
|
||||
@@ -81,3 +79,38 @@ jobs:
|
||||
path: ci-shards/
|
||||
retention-days: 14
|
||||
if-no-files-found: warn
|
||||
|
||||
relay_integration:
|
||||
name: relay integration node ${{ fromJSON(inputs.node_versions)[0] }}
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
native-runtime: node
|
||||
node-version: ${{ fromJSON(inputs.node_versions)[0] }}
|
||||
cache-electron-package: 'true'
|
||||
cache-dependency-path: |
|
||||
pnpm-lock.yaml
|
||||
cloud/pnpm-lock.yaml
|
||||
|
||||
# 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
|
||||
working-directory: cloud
|
||||
run: |
|
||||
npx --yes pnpm@10.24.0 --filter '@orca-cloud/relay...' install --frozen-lockfile --ignore-scripts
|
||||
npx --yes pnpm@10.24.0 --filter '@orca-cloud/relay^...' build
|
||||
|
||||
- name: Test relay integration contracts
|
||||
env:
|
||||
ORCA_BACKGROUND_LAUNCH: '1'
|
||||
run: >-
|
||||
pnpm exec vitest run --config config/vitest.config.ts
|
||||
tests/e2e/relay-region-compatibility.unit.test.ts
|
||||
tests/e2e/relay-region-correction.unit.test.ts
|
||||
|
||||
@@ -55,6 +55,9 @@ jobs:
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
cache-dependency-path: |
|
||||
pnpm-lock.yaml
|
||||
mobile/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
@@ -67,6 +70,9 @@ jobs:
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: dist/orca-windows-setup.exe
|
||||
# The mobile page trees are in the key because beforePack builds the mobile web bundle
|
||||
# into the installer; src/** and config/** already cover src/mobile-web and the two
|
||||
# bundle builders. A mobile-only change must miss this cache, not reuse a stale exe.
|
||||
key: >-
|
||||
crash-survival-installer-${{ hashFiles(
|
||||
'src/**',
|
||||
@@ -85,7 +91,12 @@ jobs:
|
||||
'.npmrc',
|
||||
'package.json',
|
||||
'pnpm-lock.yaml',
|
||||
'pnpm-workspace.yaml'
|
||||
'pnpm-workspace.yaml',
|
||||
'mobile/package.json',
|
||||
'mobile/pnpm-lock.yaml',
|
||||
'mobile/app/**',
|
||||
'mobile/src/**',
|
||||
'mobile/web-entry/**'
|
||||
) }}
|
||||
|
||||
# Why: production edits miss the installer cache by design, but Electron
|
||||
@@ -101,6 +112,12 @@ jobs:
|
||||
restore-keys: |
|
||||
crash-survival-electron-builder-
|
||||
|
||||
# Why here: electron-builder's beforePack requires out/mobile-web, and the bundle
|
||||
# build resolves React Native and Expo from mobile/node_modules. Gated with the
|
||||
# build it feeds, so a cache hit does not pay for an install nothing consumes.
|
||||
- uses: ./.github/actions/install-mobile-dependencies
|
||||
if: steps.cache-installer.outputs.cache-hit != 'true'
|
||||
|
||||
- name: Build Windows installer (unsigned)
|
||||
if: steps.cache-installer.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
|
||||
@@ -75,6 +75,12 @@ jobs:
|
||||
path: dist/orca-windows-setup.exe
|
||||
key: branch-installer-${{ hashFiles('src/**', 'config/**', 'native/**', 'resources/win32/**', 'package.json', 'pnpm-lock.yaml') }}
|
||||
|
||||
# Why here: electron-builder's beforePack requires out/mobile-web, and the bundle
|
||||
# build resolves React Native and Expo from mobile/node_modules. Gated with the
|
||||
# build it feeds, so a cache hit does not pay for an install nothing consumes.
|
||||
- uses: ./.github/actions/install-mobile-dependencies
|
||||
if: steps.cache-installer.outputs.cache-hit != 'true'
|
||||
|
||||
- name: Build Windows installer (unsigned)
|
||||
if: steps.cache-installer.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
|
||||
@@ -57,6 +57,9 @@ jobs:
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
cache-dependency-path: |
|
||||
pnpm-lock.yaml
|
||||
mobile/pnpm-lock.yaml
|
||||
|
||||
- name: Cache electron-builder downloads
|
||||
uses: actions/cache@v5
|
||||
@@ -78,6 +81,10 @@ jobs:
|
||||
retry_wait_seconds: 30
|
||||
command: pnpm install --frozen-lockfile
|
||||
|
||||
# Why here: electron-builder's beforePack requires out/mobile-web, and the bundle
|
||||
# build resolves React Native and Expo from mobile/node_modules.
|
||||
- uses: ./.github/actions/install-mobile-dependencies
|
||||
|
||||
# Why: rehearsal builds are never published, so the official-build
|
||||
# secrets (telemetry key, diagnostics URL) are intentionally omitted.
|
||||
- name: Build app
|
||||
|
||||
@@ -98,11 +98,20 @@ docs/**
|
||||
# The deployable docs app is source, not local engineering notes.
|
||||
!docs/site/
|
||||
!docs/site/**
|
||||
!docs/audits/
|
||||
!docs/audits/closed-editor-model-lifetime/
|
||||
!docs/audits/closed-editor-model-lifetime/**
|
||||
!docs/assets/
|
||||
!docs/assets/**
|
||||
!docs/audits/
|
||||
!docs/audits/plugin-uninstall-log-retirement/
|
||||
!docs/audits/plugin-uninstall-log-retirement/**
|
||||
!docs/readme/
|
||||
!docs/readme/**
|
||||
!docs/STYLEGUIDE.md
|
||||
!docs/audits/
|
||||
!docs/audits/crashpad-read-limit/
|
||||
!docs/audits/crashpad-read-limit/source-hashes.json
|
||||
!docs/agent-skill-sharing-implementation-checklist.md
|
||||
!docs/mobile-terminal-shortcut-bar.md
|
||||
!docs/reference/
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
Monitor and steer your agents from your phone — get notified when an agent finishes and send follow-ups from anywhere.
|
||||
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.48](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.48/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [TestFlight](https://testflight.apple.com/join/YjeGMQBA) · [Android APK 0.0.50](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.50/app-release.apk) · [Docs →](https://www.onorca.dev/docs/mobile)
|
||||
|
||||
</td>
|
||||
<td width="50%">
|
||||
@@ -230,7 +230,7 @@ yay -S stably-orca-bin
|
||||
Pair with your desktop app to monitor and steer your agents from your phone.
|
||||
|
||||
- **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) or [join TestFlight](https://testflight.apple.com/join/YjeGMQBA)
|
||||
- **Android:** [Download APK 0.0.48](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.48/app-release.apk) · [Install guide](https://www.onorca.dev/docs/android-apk)
|
||||
- **Android:** [Download APK 0.0.50](https://github.com/stablyai/orca/releases/download/mobile-android-v0.0.50/app-release.apk) · [Install guide](https://www.onorca.dev/docs/android-apk)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -242,9 +242,16 @@ describe('relay incident live preflight', () => {
|
||||
await expect(runIncidentLivePreflight(
|
||||
['--state-file', agedState(235 * 60_000 + 1), '--wave-index', '3'], deps
|
||||
)).rejects.toThrow('monitor evidence is incomplete or stale')
|
||||
// The wave index is a strict single-use 0-3 argument.
|
||||
// The last cell of a ten-cell same-cap batch: 10min + 9 * 75min exactly.
|
||||
await expect(runIncidentLivePreflight(
|
||||
['--state-file', stateFile(), '--wave-index', '4'], deps
|
||||
['--state-file', agedState(685 * 60_000), '--wave-index', '9'], deps
|
||||
)).resolves.toBeUndefined()
|
||||
await expect(runIncidentLivePreflight(
|
||||
['--state-file', agedState(685 * 60_000 + 1), '--wave-index', '9'], deps
|
||||
)).rejects.toThrow('monitor evidence is incomplete or stale')
|
||||
// The wave index is a strict single-use 0-9 argument.
|
||||
await expect(runIncidentLivePreflight(
|
||||
['--state-file', stateFile(), '--wave-index', '10'], deps
|
||||
)).rejects.toThrow('usage:')
|
||||
await expect(runIncidentLivePreflight(
|
||||
['--state-file', stateFile(), '--wave-index', ''], deps
|
||||
|
||||
@@ -30,7 +30,9 @@ const FRESHNESS_RETRY_INTERVAL_MS = 15_000
|
||||
const MONITOR_EVIDENCE_MAX_AGE_MS = 10 * 60_000
|
||||
// Matches the same-cap cell job timeout-minutes; bounds each predecessor wave.
|
||||
const WAVE_PREDECESSOR_TIMEOUT_MS = 75 * 60_000
|
||||
const WAVE_INDEX_PATTERN = /^[0-3]$/
|
||||
// Widest any wave chain declares (same-cap's cell_1..cell_10); each job workflow
|
||||
// pins its own narrower range.
|
||||
const WAVE_INDEX_PATTERN = /^[0-9]$/
|
||||
// 2 for a general cell's isolate-and-restore wave, 0 for a migration-only cell's no-op pair.
|
||||
const SELECTOR_WAVE_DELTA_PATTERN = /^[02]$/
|
||||
|
||||
|
||||
+41
-20
@@ -2,7 +2,7 @@ import {
|
||||
AssignmentRequestSchema,
|
||||
IdleRegionalRehomeRequestSchema,
|
||||
type IdleRegionalRehomeRequest,
|
||||
type IdleRegionalRehomeOutcome,
|
||||
type IdleRegionalRehomeResult,
|
||||
type RegionCorrectionResponse,
|
||||
isRelayCellConnectionHardCap,
|
||||
RELAY_ADMISSION_BUDGETS,
|
||||
@@ -27,10 +27,11 @@ import {
|
||||
createRegionalRehomeTokenVerifier,
|
||||
createRuntimeTokenVerifier
|
||||
} from './admin-token-verifier.js'
|
||||
import type {
|
||||
CellFenceAttemptEvidence,
|
||||
RelayAssignment,
|
||||
RelayAssignmentStore
|
||||
import {
|
||||
RelayHomeCellUnavailableError,
|
||||
type CellFenceAttemptEvidence,
|
||||
type RelayAssignment,
|
||||
type RelayAssignmentStore
|
||||
} from './assignment-store.js'
|
||||
import { AssignmentRejectionLogWindow } from './assignment-rejection-log-window.js'
|
||||
import { CELL_ADMISSION_STATES } from './cell-admission-selector.js'
|
||||
@@ -78,7 +79,7 @@ export function createRelayApp(
|
||||
idleRehome?: (input: IdleRegionalRehomeRequest & {
|
||||
cohortPercent: number
|
||||
directorSafety: RegionalRehomeSafetySnapshot
|
||||
}) => Promise<{ outcome: IdleRegionalRehomeOutcome }>
|
||||
}) => Promise<IdleRegionalRehomeResult>
|
||||
drainHost?: (input: {
|
||||
attemptId: string
|
||||
userId: string
|
||||
@@ -361,16 +362,17 @@ export function createRelayApp(
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (isRelayAssignmentCapacityError(error) || isRelayDatabaseTransientError(error)) {
|
||||
if (isRelayAssignmentUnavailableError(error) || isRelayDatabaseTransientError(error)) {
|
||||
logAssignmentRejection({
|
||||
route: 'assign',
|
||||
lane,
|
||||
hinted: Boolean(body.data.reconnect),
|
||||
relayHostId: claims.relayHostId,
|
||||
reason: operationError(error)
|
||||
reason: operationError(error),
|
||||
...homeCellRejectionDetail(error)
|
||||
})
|
||||
}
|
||||
if (isRelayAssignmentCapacityError(error)) {
|
||||
if (isRelayAssignmentUnavailableError(error)) {
|
||||
if (lane === 'placement') {
|
||||
operations.recordRegionSelection?.({ targetRegion, fallback: false })
|
||||
}
|
||||
@@ -389,11 +391,13 @@ export function createRelayApp(
|
||||
fallback: lane === 'placement' && assignment.region !== targetRegion
|
||||
})
|
||||
// Grant-side counterpart of the rejection log: reconnect grants are rare
|
||||
// enough to log and make "which cell is this host on" answerable.
|
||||
if (lane === 'sticky') {
|
||||
// enough to log and make "which cell is this host on" answerable. The
|
||||
// placement-lane ones matter most — they are the only record that a host
|
||||
// whose sticky lane failed verification landed anywhere at all.
|
||||
if (body.data.reconnect) {
|
||||
console.warn(
|
||||
`[orca-relay] assignment granted lane=sticky host=${relayHostLogDigest(claims.relayHostId)}` +
|
||||
` cell=${assignment.cellId}`
|
||||
`[orca-relay] assignment granted lane=${lane} hinted=true` +
|
||||
` host=${relayHostLogDigest(claims.relayHostId)} cell=${assignment.cellId}`
|
||||
)
|
||||
}
|
||||
const lease = await new SignJWT({
|
||||
@@ -466,16 +470,17 @@ export function createRelayApp(
|
||||
leaseExpiresAt: assignment.leaseExpiresAt
|
||||
})
|
||||
} catch (error) {
|
||||
if (isRelayAssignmentCapacityError(error) || isRelayDatabaseTransientError(error)) {
|
||||
if (isRelayAssignmentUnavailableError(error) || isRelayDatabaseTransientError(error)) {
|
||||
logAssignmentRejection({
|
||||
route: 'resolve',
|
||||
lane: 'none',
|
||||
hinted: false,
|
||||
relayHostId: body.data.relayHostId,
|
||||
reason: operationError(error)
|
||||
reason: operationError(error),
|
||||
...homeCellRejectionDetail(error)
|
||||
})
|
||||
}
|
||||
if (isRelayAssignmentCapacityError(error)) {
|
||||
if (isRelayAssignmentUnavailableError(error)) {
|
||||
return context.json({ error: operationError(error) }, 503)
|
||||
}
|
||||
if (isRelayDatabaseTransientError(error)) return rejectPublicAssignment(context)
|
||||
@@ -1948,25 +1953,41 @@ function logAssignmentRejection(input: {
|
||||
hinted: boolean
|
||||
relayHostId: string
|
||||
reason: string
|
||||
cause?: string
|
||||
cell?: string
|
||||
suppressed?: number
|
||||
}): void {
|
||||
console.warn(
|
||||
`[orca-relay] assignment rejected route=${input.route} lane=${input.lane}` +
|
||||
` hinted=${input.hinted} reason=${input.reason}` +
|
||||
` host=${relayHostLogDigest(input.relayHostId)}` +
|
||||
(input.cause === undefined ? '' : ` cause=${input.cause}`) +
|
||||
(input.cell === undefined ? '' : ` cell=${input.cell}`) +
|
||||
(input.suppressed === undefined ? '' : ` suppressed=${input.suppressed}`)
|
||||
)
|
||||
}
|
||||
|
||||
function isRelayAssignmentCapacityError(error: unknown): boolean {
|
||||
// The home-cell reason is not capacity, but it is the same answer to the client:
|
||||
// retry, the director cannot place you right now.
|
||||
function isRelayAssignmentUnavailableError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
['relay_capacity_exhausted', 'relay_connection_headroom_exhausted'].includes(
|
||||
error.message
|
||||
)
|
||||
[
|
||||
'relay_capacity_exhausted',
|
||||
'relay_connection_headroom_exhausted',
|
||||
'relay_home_cell_unavailable'
|
||||
].includes(error.message)
|
||||
)
|
||||
}
|
||||
|
||||
function homeCellRejectionDetail(
|
||||
error: unknown
|
||||
): { cause: string; cell: string } | Record<string, never> {
|
||||
return error instanceof RelayHomeCellUnavailableError
|
||||
? { cause: error.unavailableCause, cell: error.cellId }
|
||||
: {}
|
||||
}
|
||||
|
||||
function isCanonicalRelayOrigin(value: string): boolean {
|
||||
const url = new URL(value)
|
||||
const loopback = ['127.0.0.1', 'localhost', '::1', '[::1]'].includes(url.hostname)
|
||||
|
||||
@@ -16,6 +16,7 @@ function stubStore(overrides: Partial<AssignmentCleanupStore> = {}) {
|
||||
completeReadyEvacuations: method('completeReadyEvacuations'),
|
||||
completeReadyRegionalRehomes: method('completeReadyRegionalRehomes'),
|
||||
abortExpiredEvacuations: method('abortExpiredEvacuations'),
|
||||
abortUnarrivedRegionalRehomes: method('abortUnarrivedRegionalRehomes'),
|
||||
abortExpiredRegionalRehomes: method('abortExpiredRegionalRehomes'),
|
||||
reapRegionalRehomeAttempts: method('reapRegionalRehomeAttempts'),
|
||||
releaseExpiredActivityLeases: method('releaseExpiredActivityLeases'),
|
||||
@@ -42,6 +43,7 @@ describe('assignment cleanup steps', () => {
|
||||
'refreshRegionalRehomeLeases',
|
||||
'completeReadyEvacuations',
|
||||
'abortExpiredEvacuations',
|
||||
'abortUnarrivedRegionalRehomes',
|
||||
'abortExpiredRegionalRehomes',
|
||||
'reapRegionalRehomeAttempts',
|
||||
'releaseExpiredActivityLeases',
|
||||
@@ -55,13 +57,13 @@ describe('assignment cleanup steps', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('covers all ten sweeps exactly once per run', async () => {
|
||||
it('covers all eleven sweeps exactly once per run', async () => {
|
||||
const { store, calls } = stubStore()
|
||||
|
||||
await runAssignmentCleanup(store)
|
||||
|
||||
expect(calls).toHaveLength(10)
|
||||
expect(new Set(calls).size).toBe(10)
|
||||
expect(assignmentCleanupSteps(store)).toHaveLength(10)
|
||||
expect(calls).toHaveLength(11)
|
||||
expect(new Set(calls).size).toBe(11)
|
||||
expect(assignmentCleanupSteps(store)).toHaveLength(11)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { runRelayBackgroundOperation } from './relay-background-operation.js'
|
||||
|
||||
// The ten periodic assignment sweeps the director runs every 30s. Each step
|
||||
// The eleven periodic assignment sweeps the director runs every 30s. Each step
|
||||
// re-derives its state from the database and is idempotent, so they carry no
|
||||
// intra-tick ordering dependency — which is what makes per-step isolation
|
||||
// sound: one failing sweep costs one tick of itself, never the other nine.
|
||||
// sound: one failing sweep costs one tick of itself, never the other ten.
|
||||
// (A single poisoned rehome row once silenced the whole chained form
|
||||
// fleet-wide.) Sweep failures are logged, never fed into the rehome worker's
|
||||
// dispatch-failure budget: a sweep exception is not a dispatch failure and
|
||||
@@ -13,6 +13,7 @@ export type AssignmentCleanupStore = {
|
||||
completeReadyEvacuations(): Promise<unknown>
|
||||
completeReadyRegionalRehomes(): Promise<unknown>
|
||||
abortExpiredEvacuations(): Promise<unknown>
|
||||
abortUnarrivedRegionalRehomes(): Promise<unknown>
|
||||
abortExpiredRegionalRehomes(): Promise<unknown>
|
||||
reapRegionalRehomeAttempts(): Promise<unknown>
|
||||
releaseExpiredActivityLeases(): Promise<unknown>
|
||||
@@ -29,6 +30,7 @@ export function assignmentCleanupSteps(
|
||||
['complete-ready-evacuations', () => assignments.completeReadyEvacuations()],
|
||||
['complete-ready-regional-rehomes', () => assignments.completeReadyRegionalRehomes()],
|
||||
['abort-expired-evacuations', () => assignments.abortExpiredEvacuations()],
|
||||
['abort-unarrived-regional-rehomes', () => assignments.abortUnarrivedRegionalRehomes()],
|
||||
['abort-expired-regional-rehomes', () => assignments.abortExpiredRegionalRehomes()],
|
||||
['reap-regional-rehome-attempts', () => assignments.reapRegionalRehomeAttempts()],
|
||||
['release-expired-activity-leases', () => assignments.releaseExpiredActivityLeases()],
|
||||
|
||||
@@ -101,6 +101,12 @@ describePostgres('PostgreSQL control supersession', () => {
|
||||
cell.id
|
||||
]
|
||||
)
|
||||
// The store reserves a unit per control lease, so a hand-written pair has to
|
||||
// carry its own reservation or the fixture starts out of balance.
|
||||
await databases[0]!.query(
|
||||
`UPDATE relay_cells SET reserved_requests = reserved_requests + 2 WHERE cell_id = ?`,
|
||||
[cell.id]
|
||||
)
|
||||
await stores[0]!.activateControl(identity, {
|
||||
cellId: cell.id,
|
||||
assignmentEpoch: assignment.assignmentEpoch,
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { RelayAssignmentStore, RelayHomeCellUnavailableError } from './assignment-store.js'
|
||||
import type { RelayCellConfig } from './config.js'
|
||||
import { openInMemoryRelayDatabase, type RelayDatabase } from './database.js'
|
||||
|
||||
const HEARTBEAT_TTL_MS = 45_000
|
||||
const START_MS = 100
|
||||
const IDENTITY = { userId: 'user-1', relayHostId: 'host000000000001' }
|
||||
|
||||
// A connection-limited cell is what makes the committed fence mandatory, and
|
||||
// that is the branch which used to answer "capacity exhausted".
|
||||
const FENCED_CELL: RelayCellConfig = {
|
||||
id: 'home',
|
||||
url: 'https://home.example.com',
|
||||
capacityRequests: 1_000,
|
||||
connectionHardCap: 600,
|
||||
connectionUnobservedBound: 50
|
||||
}
|
||||
|
||||
const databases: RelayDatabase[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
for (const database of databases.splice(0)) await database.close()
|
||||
})
|
||||
|
||||
interface Harness {
|
||||
store: RelayAssignmentStore
|
||||
heartbeat: (cell: RelayCellConfig, ready: boolean) => Promise<void>
|
||||
setNow: (value: number) => void
|
||||
}
|
||||
|
||||
async function setup(cells: RelayCellConfig[] = [FENCED_CELL]): Promise<Harness> {
|
||||
const database = await openInMemoryRelayDatabase()
|
||||
databases.push(database)
|
||||
let now = START_MS
|
||||
const store = new RelayAssignmentStore(database, () => now, {
|
||||
requireLiveCells: true,
|
||||
heartbeatTtlMs: HEARTBEAT_TTL_MS
|
||||
})
|
||||
await store.reconcileCells(cells, true)
|
||||
const heartbeat = async (cell: RelayCellConfig, ready: boolean): Promise<void> => {
|
||||
await store.recordCellHeartbeat({
|
||||
cellId: cell.id,
|
||||
cellUrl: cell.url,
|
||||
cellIncarnation: `1111111${cells.indexOf(cell)}-1111-4111-8111-111111111111`,
|
||||
startedAt: 50,
|
||||
ready,
|
||||
observedRequests: 0,
|
||||
...(cell.connectionHardCap === undefined
|
||||
? {}
|
||||
: {
|
||||
totalConnections: 0,
|
||||
inFlightConnections: 0,
|
||||
reservedConnectionUnits: 0,
|
||||
enforcedConnectionUnits: 0,
|
||||
connectionHardCap: cell.connectionHardCap,
|
||||
connectionUnobservedBound: cell.connectionUnobservedBound
|
||||
})
|
||||
})
|
||||
}
|
||||
for (const cell of cells) await heartbeat(cell, true)
|
||||
return { store, heartbeat, setNow: (value: number) => (now = value) }
|
||||
}
|
||||
|
||||
async function assignFailure(store: RelayAssignmentStore): Promise<unknown> {
|
||||
return await store.assign(IDENTITY).then(
|
||||
() => new Error('assign unexpectedly succeeded'),
|
||||
(error: unknown) => error
|
||||
)
|
||||
}
|
||||
|
||||
function homeCellError(error: unknown): RelayHomeCellUnavailableError {
|
||||
expect(error).toBeInstanceOf(RelayHomeCellUnavailableError)
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the assertion above.
|
||||
return error as RelayHomeCellUnavailableError
|
||||
}
|
||||
|
||||
describe('home cell unavailable', () => {
|
||||
it('names a readiness failure rather than reporting fleet capacity', async () => {
|
||||
const { store, heartbeat, setNow } = await setup()
|
||||
await store.assign(IDENTITY)
|
||||
setNow(START_MS + 1_000)
|
||||
await heartbeat(FENCED_CELL, false)
|
||||
|
||||
const error = homeCellError(await assignFailure(store))
|
||||
|
||||
expect(error.message).toBe('relay_home_cell_unavailable')
|
||||
expect(error.unavailableCause).toBe('not_ready')
|
||||
expect(error.cellId).toBe(FENCED_CELL.id)
|
||||
})
|
||||
|
||||
it('names a heartbeat gap as unheard even though the cell last reported ready', async () => {
|
||||
const { store, setNow } = await setup()
|
||||
await store.assign(IDENTITY)
|
||||
setNow(START_MS + HEARTBEAT_TTL_MS + 1)
|
||||
|
||||
expect(homeCellError(await assignFailure(store)).unavailableCause).toBe('unheard')
|
||||
})
|
||||
|
||||
it('names a drained cell as draining ahead of its heartbeat gap', async () => {
|
||||
const { store, setNow } = await setup()
|
||||
await store.assign(IDENTITY)
|
||||
await store.configureCell(FENCED_CELL, false)
|
||||
setNow(START_MS + HEARTBEAT_TTL_MS + 1)
|
||||
|
||||
expect(homeCellError(await assignFailure(store)).unavailableCause).toBe('draining')
|
||||
})
|
||||
|
||||
it('still reports capacity exhaustion when the fleet has no headroom', async () => {
|
||||
const { store } = await setup([{ ...FENCED_CELL, capacityRequests: 1 }])
|
||||
await store.assign(IDENTITY)
|
||||
|
||||
await expect(
|
||||
store.assign({ userId: 'user-2', relayHostId: 'host000000000002' })
|
||||
).rejects.toThrow('relay_capacity_exhausted')
|
||||
})
|
||||
|
||||
it('rehomes instead of rejecting when the unavailable cell needs no fence', async () => {
|
||||
const home: RelayCellConfig = {
|
||||
id: 'home',
|
||||
url: 'https://home.example.com',
|
||||
capacityRequests: 1_000
|
||||
}
|
||||
const spare: RelayCellConfig = {
|
||||
id: 'spare',
|
||||
url: 'https://spare.example.com',
|
||||
capacityRequests: 1_000
|
||||
}
|
||||
const { store, heartbeat, setNow } = await setup([home, spare])
|
||||
expect((await store.assign(IDENTITY)).cellId).toBe(home.id)
|
||||
setNow(START_MS + 1_000)
|
||||
await heartbeat(home, false)
|
||||
|
||||
expect((await store.assign(IDENTITY)).cellId).toBe(spare.id)
|
||||
})
|
||||
})
|
||||
@@ -71,6 +71,7 @@ describe('assignment inventory snapshot', () => {
|
||||
targetRegistered: 0,
|
||||
completedLast24Hours: 0,
|
||||
abortedLast24Hours: 0,
|
||||
hostNotArrivedLast24Hours: 0,
|
||||
oldestActiveAgeMs: null
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { RELAY_DEFAULT_REGION } from '@orca-cloud/relay-contract'
|
||||
import type { RelayDatabase, SqlRow } from './database.js'
|
||||
import { REGIONAL_REHOME_ABORT_REPORT_WINDOW_MS } from './regional-rehome-abort-reason.js'
|
||||
|
||||
export type CellInventorySnapshotRow = {
|
||||
cellId: string
|
||||
@@ -22,6 +23,7 @@ export type AssignmentInventorySnapshot = {
|
||||
targetRegistered: number
|
||||
completedLast24Hours: number
|
||||
abortedLast24Hours: number
|
||||
hostNotArrivedLast24Hours: number
|
||||
oldestActiveAgeMs: number | null
|
||||
}
|
||||
}
|
||||
@@ -79,6 +81,10 @@ export async function readAssignmentInventorySnapshot(
|
||||
AS completed_last_24_hours,
|
||||
COALESCE(SUM(CASE WHEN attempt.aborted_at >= ? THEN 1 ELSE 0 END), 0)
|
||||
AS aborted_last_24_hours,
|
||||
COALESCE(SUM(CASE WHEN attempt.aborted_at >= ?
|
||||
AND attempt.abort_reason = 'host_not_arrived'
|
||||
THEN 1 ELSE 0 END), 0)
|
||||
AS host_not_arrived_last_24_hours,
|
||||
MIN(CASE WHEN attempt.completed_at IS NULL AND attempt.aborted_at IS NULL
|
||||
THEN attempt.created_at END) AS oldest_active_at
|
||||
FROM relay_region_rehome_attempts attempt
|
||||
@@ -86,7 +92,11 @@ export async function readAssignmentInventorySnapshot(
|
||||
ON migration.user_id = attempt.user_id
|
||||
AND migration.relay_host_id = attempt.relay_host_id
|
||||
AND migration.assignment_epoch = attempt.assignment_epoch`,
|
||||
[now - 24 * 60 * 60_000, now - 24 * 60 * 60_000]
|
||||
[
|
||||
now - REGIONAL_REHOME_ABORT_REPORT_WINDOW_MS,
|
||||
now - REGIONAL_REHOME_ABORT_REPORT_WINDOW_MS,
|
||||
now - REGIONAL_REHOME_ABORT_REPORT_WINDOW_MS
|
||||
]
|
||||
)
|
||||
)[0]
|
||||
const oldestActiveAt = optionalInteger(regionalRehomeRow, 'oldest_active_at')
|
||||
@@ -117,6 +127,10 @@ export async function readAssignmentInventorySnapshot(
|
||||
targetRegistered: asInteger(regionalRehomeRow, 'target_registered'),
|
||||
completedLast24Hours: asInteger(regionalRehomeRow, 'completed_last_24_hours'),
|
||||
abortedLast24Hours: asInteger(regionalRehomeRow, 'aborted_last_24_hours'),
|
||||
hostNotArrivedLast24Hours: asInteger(
|
||||
regionalRehomeRow,
|
||||
'host_not_arrived_last_24_hours'
|
||||
),
|
||||
oldestActiveAgeMs: oldestActiveAt === null ? null : now - oldestActiveAt
|
||||
}
|
||||
}
|
||||
@@ -147,6 +161,7 @@ export function formatAssignmentInventorySnapshot(
|
||||
` targetRegistered=${snapshot.regionalRehomes.targetRegistered}` +
|
||||
` completedLast24Hours=${snapshot.regionalRehomes.completedLast24Hours}` +
|
||||
` abortedLast24Hours=${snapshot.regionalRehomes.abortedLast24Hours}` +
|
||||
` hostNotArrivedLast24Hours=${snapshot.regionalRehomes.hostNotArrivedLast24Hours}` +
|
||||
` oldestActiveAgeMs=${snapshot.regionalRehomes.oldestActiveAgeMs ?? 'none'}`
|
||||
)
|
||||
return lines
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RelayAssignment } from './assignment-store.js'
|
||||
import { RelayHomeCellUnavailableError, type RelayAssignment } from './assignment-store.js'
|
||||
import type { RelayConfig } from './config.js'
|
||||
|
||||
const fakes = vi.hoisted(() => ({
|
||||
@@ -52,6 +52,39 @@ describe('assignment rejection logging', () => {
|
||||
expect(line).not.toContain(host)
|
||||
})
|
||||
|
||||
it('separates an unavailable home cell from capacity and names its cause', async () => {
|
||||
const host = 'cccccccccccccccc'
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const app = createRelayApp(config(), {
|
||||
store: {} as never,
|
||||
assignments: {
|
||||
assign: vi.fn(async () => {
|
||||
throw new RelayHomeCellUnavailableError('cell-asia-1', 'not_ready')
|
||||
}),
|
||||
// The sticky lane refuses a host whose home cell is not live, so this
|
||||
// arrives hinted on the placement lane.
|
||||
resolve: vi.fn(async () => null)
|
||||
} as never,
|
||||
drain: vi.fn(),
|
||||
ready: vi.fn(async () => true)
|
||||
})
|
||||
|
||||
const response = await app.request('/v1/assign', assignmentRequest(host, { reconnect: true }))
|
||||
|
||||
expect(response.status).toBe(503)
|
||||
expect(await response.json()).toEqual({ error: 'relay_home_cell_unavailable' })
|
||||
const line = warn.mock.calls.map((call) => String(call[0])).find((entry) =>
|
||||
entry.includes('assignment rejected')
|
||||
)
|
||||
expect(line).toContain('lane=placement')
|
||||
expect(line).toContain('hinted=true')
|
||||
expect(line).toContain('reason=relay_home_cell_unavailable')
|
||||
expect(line).toContain('cause=not_ready')
|
||||
expect(line).toContain('cell=cell-asia-1')
|
||||
expect(line).not.toContain('relay_capacity_exhausted')
|
||||
expect(line).not.toContain(host)
|
||||
})
|
||||
|
||||
it('logs an unhinted placement rejection without the raw host id', async () => {
|
||||
const host = 'gggggggggggggggg'
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
@@ -221,6 +254,31 @@ describe('assignment grant logging', () => {
|
||||
expect(line).not.toContain(host)
|
||||
})
|
||||
|
||||
it('logs a hinted grant served by the placement lane', async () => {
|
||||
const host = 'rrrrrrrrrrrrrrrr'
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const app = createRelayApp(config(), {
|
||||
store: {} as never,
|
||||
assignments: {
|
||||
assign: vi.fn(async () => assignment('cell-new', host)),
|
||||
resolve: vi.fn(async () => null)
|
||||
} as never,
|
||||
drain: vi.fn(),
|
||||
ready: vi.fn(async () => true)
|
||||
})
|
||||
|
||||
const response = await app.request('/v1/assign', assignmentRequest(host, { reconnect: true }))
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const line = warn.mock.calls.map((call) => String(call[0])).find((entry) =>
|
||||
entry.includes('assignment granted')
|
||||
)
|
||||
expect(line).toContain('lane=placement')
|
||||
expect(line).toContain('hinted=true')
|
||||
expect(line).toContain('cell=cell-new')
|
||||
expect(line).not.toContain(host)
|
||||
})
|
||||
|
||||
it('does not log unhinted placement grants', async () => {
|
||||
const host = 'pppppppppppppppp'
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { RelayAssignmentStore } from './assignment-store.js'
|
||||
import { openRelayDatabase, type RelayDatabase } from './database.js'
|
||||
|
||||
const databaseUrl = process.env.ORCA_RELAY_TEST_POSTGRES_URL
|
||||
const describePostgres = databaseUrl ? describe : describe.skip
|
||||
|
||||
const cells = [
|
||||
{
|
||||
id: 'row-lock-order-a',
|
||||
url: 'https://row-lock-order-a.example.com',
|
||||
capacityRequests: 200,
|
||||
connectionHardCap: 600 as const,
|
||||
connectionUnobservedBound: 50
|
||||
},
|
||||
{
|
||||
id: 'row-lock-order-b',
|
||||
url: 'https://row-lock-order-b.example.com',
|
||||
capacityRequests: 200,
|
||||
connectionHardCap: 600 as const,
|
||||
connectionUnobservedBound: 50
|
||||
}
|
||||
]
|
||||
const userId = 'row-lock-order-user'
|
||||
const hosts = ['rowlockhost00001', 'rowlockhost00002', 'rowlockhost00003', 'rowlockhost00004'].map(
|
||||
(relayHostId) => ({ userId, relayHostId })
|
||||
)
|
||||
|
||||
// Why a deadlock counter and not "it eventually succeeded": the transaction
|
||||
// wrapper retries 40P01 three times, so a cycle that fires on every wave still
|
||||
// reports success to the caller while burning the retry budget that turns into
|
||||
// a 503 under load. PostgreSQL counts every detected cycle in pg_stat_database,
|
||||
// which sees through the retry.
|
||||
describePostgres('PostgreSQL row lock order', () => {
|
||||
const databases: RelayDatabase[] = []
|
||||
|
||||
beforeAll(async () => {
|
||||
for (let index = 0; index < 4; index++) {
|
||||
databases.push(await openRelayDatabase({ databaseUrl, dataDir: '' }))
|
||||
}
|
||||
})
|
||||
|
||||
async function removeTestRows(database: RelayDatabase): Promise<void> {
|
||||
await database.query(
|
||||
`DELETE FROM relay_control_connection_reservations WHERE user_id = ?`,
|
||||
[userId]
|
||||
)
|
||||
for (const table of [
|
||||
'relay_control_capabilities',
|
||||
'relay_assignment_activity_leases',
|
||||
'relay_post_drain_migration_pins',
|
||||
'relay_assignment_migration_incarnations',
|
||||
'relay_assignment_migrations',
|
||||
'relay_assignments'
|
||||
]) {
|
||||
await database.query(`DELETE FROM ${table} WHERE user_id = ?`, [userId])
|
||||
}
|
||||
for (const cell of cells) {
|
||||
for (const table of [
|
||||
'relay_cell_connection_snapshots',
|
||||
'relay_cell_connection_runtime',
|
||||
'relay_cell_connection_limits',
|
||||
'relay_cell_runtime',
|
||||
'relay_cells'
|
||||
]) {
|
||||
await database.query(`DELETE FROM ${table} WHERE cell_id = ?`, [cell.id])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
afterAll(async () => {
|
||||
if (databases[0]) await removeTestRows(databases[0])
|
||||
for (const connection of databases) await connection.close()
|
||||
})
|
||||
|
||||
async function deadlockCount(): Promise<number> {
|
||||
const rows = await databases[0]!.query(
|
||||
`SELECT deadlocks FROM pg_stat_database WHERE datname = current_database()`
|
||||
)
|
||||
return Number(rows[0]!.deadlocks)
|
||||
}
|
||||
|
||||
it('runs the cell accept and the placement retry concurrently without a cycle', async () => {
|
||||
await removeTestRows(databases[0]!)
|
||||
const stores = databases.map((database) => new RelayAssignmentStore(database, () => 100))
|
||||
await stores[0]!.reconcileCells(cells)
|
||||
for (const cell of cells) {
|
||||
await stores[0]!.recordCellHeartbeat({
|
||||
cellId: cell.id,
|
||||
cellUrl: cell.url,
|
||||
cellIncarnation: '11111111-1111-4111-8111-111111111111',
|
||||
startedAt: 50,
|
||||
ready: true,
|
||||
observedRequests: 0,
|
||||
totalConnections: 0,
|
||||
inFlightConnections: 0,
|
||||
reservedConnectionUnits: 0,
|
||||
enforcedConnectionUnits: 0,
|
||||
connectionInclusionWatermark: 1,
|
||||
connectionHardCap: 600,
|
||||
connectionUnobservedBound: 50
|
||||
})
|
||||
}
|
||||
const placements = new Map<string, { cellId: string; assignmentEpoch: number }>()
|
||||
for (const identity of hosts) {
|
||||
const assignment = await stores[0]!.assign(identity)
|
||||
placements.set(identity.relayHostId, assignment)
|
||||
}
|
||||
|
||||
const before = await deadlockCount()
|
||||
// The accept path (host rows, then the cell row last) against the paths
|
||||
// that must read the inventory first: placement and evacuation.
|
||||
for (let round = 0; round < 12; round++) {
|
||||
await Promise.allSettled(
|
||||
hosts.flatMap((identity, index) => {
|
||||
const placement = placements.get(identity.relayHostId)!
|
||||
const store = stores[index % stores.length]!
|
||||
const other = stores[(index + 1) % stores.length]!
|
||||
return [
|
||||
store.activateControl(identity, {
|
||||
cellId: placement.cellId,
|
||||
assignmentEpoch: placement.assignmentEpoch,
|
||||
generation: round + 2
|
||||
}),
|
||||
other.assign(identity),
|
||||
other.startEvacuation(
|
||||
identity,
|
||||
placement.cellId === cells[0]!.id ? cells[1]!.id : cells[0]!.id
|
||||
)
|
||||
]
|
||||
})
|
||||
)
|
||||
}
|
||||
const after = await deadlockCount()
|
||||
|
||||
expect(after - before).toBe(0)
|
||||
}, 120_000)
|
||||
})
|
||||
@@ -1886,6 +1886,11 @@ describe('RelayAssignmentStore', () => {
|
||||
'control:cell-b:3'
|
||||
]
|
||||
)
|
||||
// The store reserves a unit per control lease, so a hand-written pair has to
|
||||
// carry its own reservation or the fixture starts out of balance.
|
||||
await database!.query(
|
||||
`UPDATE relay_cells SET reserved_requests = reserved_requests + 2 WHERE cell_id = 'cell-b'`
|
||||
)
|
||||
const latest = await store.activateControl(identity, {
|
||||
cellId: 'cell-b',
|
||||
assignmentEpoch: migration.assignmentEpoch,
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { createDrainMigrationRowLookup } from './drain-migration-row-lookup.js'
|
||||
import { IDLE_REHOME_PAGE_SIZE, selectIdleRegionalRehomes } from './idle-regional-rehome-selection.js'
|
||||
import {
|
||||
selectIdleRegionalRehomes,
|
||||
type IdleRegionalRehomeCandidate,
|
||||
type IdleRehomeHostCursor
|
||||
} from './idle-regional-rehome-selection.js'
|
||||
import {
|
||||
RegionalRehomePollTelemetry,
|
||||
type RegionalRehomePollGate
|
||||
} from './regional-rehome-poll-telemetry.js'
|
||||
import { readRegionCorrectionOutcomes } from './region-correction-outcomes.js'
|
||||
import {
|
||||
previewRegionalRehomeEligibility,
|
||||
@@ -22,6 +30,7 @@ import {
|
||||
type RelayRegion,
|
||||
type RegionCorrectionRequest,
|
||||
type RegionCorrectionResponse,
|
||||
type IdleRegionalRehomeCommit,
|
||||
type IdleRegionalRehomeRequest,
|
||||
} from '@orca-cloud/relay-contract'
|
||||
import {
|
||||
@@ -72,6 +81,10 @@ import {
|
||||
regionalRehomePoolPressure,
|
||||
regionalRehomeSafetyFailure
|
||||
} from './regional-rehome-safety.js'
|
||||
import {
|
||||
REGIONAL_REHOME_ARRIVAL_WINDOW_MS,
|
||||
type RegionalRehomeAbortReason
|
||||
} from './regional-rehome-abort-reason.js'
|
||||
import {
|
||||
ABANDONED_REGISTERED_MIGRATION,
|
||||
DURABLY_FENCED_MIGRATION_SOURCE,
|
||||
@@ -334,6 +347,26 @@ const ACTIVITY_REQUEST_UNITS: Record<AssignmentActivityKind, number> = {
|
||||
}
|
||||
|
||||
const ASSIGNMENT_LOCK_RETRY_DEADLINE_MS = 15_000
|
||||
|
||||
// THE ROW LOCK ORDER. Every transaction that takes more than one of these
|
||||
// takes them in this order, whichever role it runs on:
|
||||
//
|
||||
// 1. relay_assignments (the host's row)
|
||||
// 2. relay_assignment_migrations
|
||||
// relay_assignment_activity_leases
|
||||
// 3. relay_control_connection_reservations (lockControlConnectionReservations)
|
||||
// 4. relay_cells (lockCellInventory / lockCellRows / the
|
||||
// conditional reservation UPDATE)
|
||||
//
|
||||
// relay_cells is last because it is the only row shared by every host on a
|
||||
// cell: a transaction that takes it early holds it across every round trip
|
||||
// that follows, and on a cell far from PostgreSQL that is what turns accepts
|
||||
// into a queue. Everything above it is per-host, so holding it longer costs
|
||||
// only that host. Paths that read the inventory to make a placement decision
|
||||
// cannot defer relay_cells, so they lock the host's rows from tiers 1-3 up
|
||||
// front instead, before the inventory, and re-check what they read afterwards.
|
||||
// Tier 1 is what serialises two transactions on the same host; the tiers below
|
||||
// it keep transactions on *different* hosts from cycling through relay_cells.
|
||||
// Why: one global FOR UPDATE over a 23-row table serialises every director and
|
||||
// cell. At the 1s pool lock_timeout each blocked waiter also holds a pooled
|
||||
// client for a full second, so the queue converts contention into pool
|
||||
@@ -391,6 +424,25 @@ class AssignmentInventoryScopeChanged extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
// Why a reason of its own: a host whose home cell is fenced-but-unattested is
|
||||
// refused regardless of fleet headroom, so reporting it as capacity sends
|
||||
// operators after capacity that was never short. Every cell boot and every
|
||||
// readiness dip produces these.
|
||||
export type RelayHomeCellUnavailableCause =
|
||||
| 'draining'
|
||||
| 'booting'
|
||||
| 'unheard'
|
||||
| 'not_ready'
|
||||
|
||||
export class RelayHomeCellUnavailableError extends Error {
|
||||
constructor(
|
||||
readonly cellId: string,
|
||||
readonly unavailableCause: RelayHomeCellUnavailableCause
|
||||
) {
|
||||
super('relay_home_cell_unavailable')
|
||||
}
|
||||
}
|
||||
|
||||
// Debt holds connection headroom for a control that may still arrive shortly
|
||||
// after its director-side timeout. Nothing legitimately arrives minutes late
|
||||
// (attach deadline 10s, orphan grace 30s); unretired debt from hosts that
|
||||
@@ -827,6 +879,9 @@ export class RelayAssignmentStore {
|
||||
let retryScope: RetriedAssignmentInventoryScope =
|
||||
inventoryScope === 'all' ? 'all' : 'general'
|
||||
return await this.database.transaction(async (transaction) => {
|
||||
// The retry paths below open with the inventory, so this path takes its
|
||||
// host rows before any of them rather than where the others do.
|
||||
await this.lockControlConnectionReservations(transaction, identity, lockMode)
|
||||
let lockedCells =
|
||||
inventoryScope === 'all'
|
||||
? await this.lockCellInventory(transaction, lockMode)
|
||||
@@ -921,7 +976,10 @@ export class RelayAssignmentStore {
|
||||
)) &&
|
||||
!(await this.cellHasCommittedFence(transaction, current.cellId, now))
|
||||
) {
|
||||
throw new Error('relay_capacity_exhausted')
|
||||
throw new RelayHomeCellUnavailableError(
|
||||
current.cellId,
|
||||
await this.homeCellUnavailableCause(transaction, current.cellId, now)
|
||||
)
|
||||
}
|
||||
forcedDeadReassignment = true
|
||||
}
|
||||
@@ -2673,7 +2731,13 @@ export class RelayAssignmentStore {
|
||||
)
|
||||
if (assignment.cellId !== text(row, 'cell_id')) moved++
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error && error.message === 'relay_capacity_exhausted')) throw error
|
||||
// One unplaceable host must not end the sweep for the rest.
|
||||
if (
|
||||
!(error instanceof RelayHomeCellUnavailableError) &&
|
||||
!(error instanceof Error && error.message === 'relay_capacity_exhausted')
|
||||
) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
return moved
|
||||
@@ -3329,49 +3393,83 @@ export class RelayAssignmentStore {
|
||||
return previewRegionCorrection(this.database, this.now())
|
||||
}
|
||||
|
||||
private idleRegionalCandidateOffset = 0
|
||||
private idleRegionalCandidateCursor: IdleRehomeHostCursor = null
|
||||
private readonly regionalRehomePollTelemetry = new RegionalRehomePollTelemetry()
|
||||
|
||||
async selectIdleRegionalRehomeCandidates(
|
||||
processSafety?: RegionalRehomeSafetySnapshot
|
||||
): Promise<Array<IdleRegionalRehomeRequest & { sourceCellUrl: string }>> {
|
||||
): Promise<IdleRegionalRehomeCandidate[]> {
|
||||
const now = this.now()
|
||||
if (!processSafety || this.regionalRehomeCohortPercent === 0) return []
|
||||
const gated = (gate: RegionalRehomePollGate): IdleRegionalRehomeCandidate[] => {
|
||||
this.regionalRehomePollTelemetry.record({ now, gate, candidates: 0 })
|
||||
return []
|
||||
}
|
||||
if (!processSafety) return gated('process-safety-unavailable')
|
||||
if (this.regionalRehomeCohortPercent === 0) return gated('cohort-zero')
|
||||
const control = (await this.database.query(
|
||||
"SELECT enabled, not_before FROM relay_region_rehome_control WHERE control_id = 'global'"
|
||||
`SELECT enabled, not_before, preference_max_age_ms, host_cooldown_ms
|
||||
FROM relay_region_rehome_control WHERE control_id = 'global'`
|
||||
))[0]
|
||||
if (!control || Number(control.enabled) !== 1 || Number(control.not_before) > now) return []
|
||||
if (!control || Number(control.enabled) !== 1 || Number(control.not_before) > now) {
|
||||
return gated('control-closed')
|
||||
}
|
||||
// The dispatch budget is durable and global, but until now only
|
||||
// `commitIdleRegionalRehome` consulted it -- after the join had already run and
|
||||
// the worker had already POSTed every candidate to its source cell. An absent
|
||||
// row means the budget has never been spent, so it opens the gate.
|
||||
const worker = (await this.database.query(
|
||||
`SELECT paused_until, next_dispatch_at FROM relay_region_rehome_worker_state
|
||||
WHERE worker_id = 'global'`
|
||||
))[0]
|
||||
if (worker && (Number(worker.paused_until) > now || Number(worker.next_dispatch_at) > now)) {
|
||||
return gated('budget-closed')
|
||||
}
|
||||
const fleetSafety = await this.readRegionalRehomeFleetSafety(this.database, now)
|
||||
if (regionalRehomeFleetSafetyFailure(processSafety, fleetSafety, now)) return []
|
||||
const candidates = await selectIdleRegionalRehomes({
|
||||
if (regionalRehomeFleetSafetyFailure(processSafety, fleetSafety, now)) return gated('fleet-safety')
|
||||
const startedAt = performance.now()
|
||||
const selection = await selectIdleRegionalRehomes({
|
||||
database: this.database, now, heartbeatTtlMs: this.heartbeatTtlMs,
|
||||
cohortPercent: this.regionalRehomeCohortPercent, offset: this.idleRegionalCandidateOffset,
|
||||
cohortPercent: this.regionalRehomeCohortPercent,
|
||||
preferenceMaxAgeMs: Number(control.preference_max_age_ms),
|
||||
hostCooldownMs: Number(control.host_cooldown_ms),
|
||||
cursor: this.idleRegionalCandidateCursor,
|
||||
connectionHeadroom: await this.connectionHeadroomByCell(this.database),
|
||||
cellIsClean: regionalRehomeCellSafetyIsClean
|
||||
})
|
||||
this.idleRegionalCandidateOffset = candidates.length < IDLE_REHOME_PAGE_SIZE
|
||||
? 0 : this.idleRegionalCandidateOffset + candidates.length
|
||||
return candidates
|
||||
this.idleRegionalCandidateCursor = selection.cursor
|
||||
this.regionalRehomePollTelemetry.record({
|
||||
now,
|
||||
gate: 'open',
|
||||
candidates: selection.candidates.length,
|
||||
selectionMs: performance.now() - startedAt
|
||||
})
|
||||
return selection.candidates
|
||||
}
|
||||
|
||||
async commitIdleRegionalRehome(
|
||||
request: IdleRegionalRehomeRequest,
|
||||
processSafety?: RegionalRehomeSafetySnapshot,
|
||||
cohortPercent = this.regionalRehomeCohortPercent
|
||||
): Promise<{ outcome: 'committed' | 'deferred' | 'stale' }> {
|
||||
): Promise<IdleRegionalRehomeCommit> {
|
||||
const prior = await this.reconcileIdleRegionalRehome(request)
|
||||
if (prior !== 'not-committed') return { outcome: prior }
|
||||
if (!processSafety || !Number.isInteger(cohortPercent) || cohortPercent <= 0 || cohortPercent > 100) {
|
||||
return { outcome: 'deferred' }
|
||||
if (!processSafety) return { outcome: 'deferred', reason: 'director-safety-stale' }
|
||||
if (!Number.isInteger(cohortPercent) || cohortPercent <= 0 || cohortPercent > 100) {
|
||||
return { outcome: 'deferred', reason: 'cohort-closed' }
|
||||
}
|
||||
let safetyDisable: Record<string, string | number> | null = null
|
||||
const result = await this.database.transaction(async (transaction): Promise<{ outcome: 'committed' | 'deferred' | 'stale' }> => {
|
||||
// Set on every fleet-safety failure, disable or not: the pause is durable
|
||||
// and global either way, so no later candidate in this poll can get past it.
|
||||
let safetyPaused = false
|
||||
const result = await this.database.transaction(async (transaction): Promise<IdleRegionalRehomeCommit> => {
|
||||
safetyDisable = null
|
||||
safetyPaused = false
|
||||
const now = this.now()
|
||||
const control = (await transaction.queryLocked(
|
||||
`SELECT * FROM relay_region_rehome_control WHERE control_id = 'global'`
|
||||
))[0]
|
||||
if (!control || Number(control.enabled) !== 1 || Number(control.not_before) > now) {
|
||||
return { outcome: 'deferred' }
|
||||
return { outcome: 'deferred', reason: 'control-closed' }
|
||||
}
|
||||
await transaction.query(
|
||||
`INSERT INTO relay_region_rehome_worker_state
|
||||
@@ -3382,13 +3480,15 @@ export class RelayAssignmentStore {
|
||||
`SELECT * FROM relay_region_rehome_worker_state WHERE worker_id = 'global'`
|
||||
))[0]!
|
||||
if (Number(worker.paused_until) > now || Number(worker.next_dispatch_at) > now) {
|
||||
return { outcome: 'deferred' }
|
||||
return { outcome: 'deferred', reason: 'budget-closed' }
|
||||
}
|
||||
const open = (await transaction.query(
|
||||
`SELECT COUNT(*) AS count FROM relay_assignment_migrations
|
||||
WHERE completed_at IS NULL AND aborted_at IS NULL`
|
||||
))[0]
|
||||
if (Number(open?.count ?? 0) >= REGIONAL_REHOME_CONCURRENT_LIMIT) return { outcome: 'deferred' }
|
||||
if (Number(open?.count ?? 0) >= REGIONAL_REHOME_CONCURRENT_LIMIT) {
|
||||
return { outcome: 'deferred', reason: 'concurrency-limit' }
|
||||
}
|
||||
const attempt = await this.startRegionalRehomeCandidate(transaction, {
|
||||
identity: request,
|
||||
sourceCellId: request.sourceCellId,
|
||||
@@ -3402,9 +3502,14 @@ export class RelayAssignmentStore {
|
||||
skips: [],
|
||||
idleRequest: request,
|
||||
cohortPercent,
|
||||
onSafetyDisabled: (event) => { safetyDisable = event }
|
||||
onSafetyDisabled: (event) => {
|
||||
safetyDisable = event
|
||||
safetyPaused = true
|
||||
}
|
||||
})
|
||||
if (!attempt) return { outcome: 'deferred' }
|
||||
if (!attempt) {
|
||||
return { outcome: 'deferred', reason: safetyPaused ? 'fleet-safety' : 'candidate-ineligible' }
|
||||
}
|
||||
await this.markRegionalRehomeDispatchClaimed(
|
||||
transaction, request.attemptId, now, Math.ceil(60_000 / Number(control.rate_per_minute))
|
||||
)
|
||||
@@ -3756,14 +3861,16 @@ export class RelayAssignmentStore {
|
||||
? activityId
|
||||
: pendingId
|
||||
: activityId
|
||||
await this.removeSupersededSameCellControls(
|
||||
// Accumulated, not applied: every cell-row write on this path is folded
|
||||
// into one conditional statement issued last, below.
|
||||
let reservationDelta = -(await this.removeSupersededSameCellControls(
|
||||
transaction,
|
||||
identity,
|
||||
activityLeases,
|
||||
input.cellId,
|
||||
retainedActivityId,
|
||||
now
|
||||
)
|
||||
))
|
||||
if (existing) {
|
||||
await transaction.query(
|
||||
`UPDATE relay_assignment_activity_leases SET expires_at = ?, updated_at = ?
|
||||
@@ -3781,7 +3888,7 @@ export class RelayAssignmentStore {
|
||||
)
|
||||
await this.touchAssignment(transaction, identity, expiresAt, now)
|
||||
} else {
|
||||
await this.adjustCellReservationAtomically(transaction, input.cellId, 1)
|
||||
reservationDelta += ACTIVITY_REQUEST_UNITS.control
|
||||
await this.adjustActivityCount(transaction, identity, 'control', 1, expiresAt, now)
|
||||
await transaction.query(
|
||||
`INSERT INTO relay_assignment_activity_leases
|
||||
@@ -3837,6 +3944,17 @@ export class RelayAssignmentStore {
|
||||
input.idleRegionalRehome && input.cellIncarnation ? 1 : 0
|
||||
]
|
||||
)
|
||||
// Last, and only if the count actually moved: the cell row is shared by
|
||||
// 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) {
|
||||
await this.adjustCellReservationAtomically(
|
||||
transaction,
|
||||
input.cellId,
|
||||
reservationDelta
|
||||
)
|
||||
}
|
||||
return activityId
|
||||
})
|
||||
})
|
||||
@@ -3881,6 +3999,7 @@ export class RelayAssignmentStore {
|
||||
}
|
||||
if (sourceCellId === targetCellId) throw new Error('target_matches_source')
|
||||
await this.lockAssignmentActivities(transaction, identity)
|
||||
await this.lockControlConnectionReservations(transaction, identity)
|
||||
const cells = await this.lockCellInventory(transaction, 'request')
|
||||
const target = cells.find((row) => text(row, 'cell_id') === targetCellId)
|
||||
if (!target || integer(target, 'enabled') !== 1) throw new Error('target_cell_unavailable')
|
||||
@@ -4086,6 +4205,7 @@ export class RelayAssignmentStore {
|
||||
): Promise<DeadSourceCompletionResult> {
|
||||
const now = this.now()
|
||||
return await this.database.transaction(async (transaction) => {
|
||||
await this.lockControlConnectionReservations(transaction, identity)
|
||||
let lockedCells: SqlRow[] | undefined
|
||||
if (inventoryFirst) {
|
||||
try {
|
||||
@@ -4233,6 +4353,7 @@ export class RelayAssignmentStore {
|
||||
): Promise<RelayAssignmentMigration> {
|
||||
const now = this.now()
|
||||
return await this.database.transaction(async (transaction) => {
|
||||
await this.lockControlConnectionReservations(transaction, identity)
|
||||
const lockedCells = inventoryFirst
|
||||
? await this.lockCellInventory(transaction, 'request')
|
||||
: undefined
|
||||
@@ -4725,7 +4846,10 @@ export class RelayAssignmentStore {
|
||||
throw new Error('migration_activity_topology_mismatch')
|
||||
}
|
||||
}
|
||||
if (obsoleteLeases.length > 0) await this.lockCellInventory(transaction, 'request')
|
||||
if (obsoleteLeases.length > 0) {
|
||||
await this.lockControlConnectionReservations(transaction, identity)
|
||||
await this.lockCellInventory(transaction, 'request')
|
||||
}
|
||||
for (const lease of obsoleteLeases) {
|
||||
await this.removeActivityLease(transaction, identity, lease, now)
|
||||
}
|
||||
@@ -5068,6 +5192,7 @@ export class RelayAssignmentStore {
|
||||
const sourceCellId = text(assignment, 'cell_id')
|
||||
if (sourceCellId === targetCellId) throw new Error('target_matches_source')
|
||||
await this.lockAssignmentActivities(transaction, identity)
|
||||
await this.lockControlConnectionReservations(transaction, identity)
|
||||
const cells = await this.lockCellInventory(transaction, 'request')
|
||||
const admission = await cellAdmissionStates(transaction)
|
||||
const targetRow = cells.find(
|
||||
@@ -5399,6 +5524,7 @@ export class RelayAssignmentStore {
|
||||
}
|
||||
const activityLeases = await this.lockAssignmentActivities(transaction, input.identity)
|
||||
assertAssignmentActivityCounts(assignment, activityLeases, 0)
|
||||
await this.lockControlConnectionReservations(transaction, input.identity, 'nowait')
|
||||
const cells = await this.lockCellInventory(transaction, 'nowait')
|
||||
const admission = await cellAdmissionStates(transaction)
|
||||
const regions = new Map(
|
||||
@@ -6245,8 +6371,43 @@ export class RelayAssignmentStore {
|
||||
return integer(completed[0]!, 'changes') + integer(aborted[0]!, 'changes')
|
||||
}
|
||||
|
||||
// A move the host never finished: it holds no activity on the source and is
|
||||
// not present at the target, so the registered migration row can do nothing
|
||||
// but occupy one of the eight concurrent slots until something clears it.
|
||||
// Rolling it back leaves the durable assignment on the source, so the host
|
||||
// lands where it started whenever it next reconnects.
|
||||
async abortUnarrivedRegionalRehomes(limit = 100): Promise<number> {
|
||||
return await this.rollBackStalledRegionalRehomes({
|
||||
sweep: 'abort-unarrived-regional-rehomes',
|
||||
minimumAttemptAgeMs: REGIONAL_REHOME_ARRIVAL_WINDOW_MS,
|
||||
abortReason: 'host_not_arrived',
|
||||
disableControl: false,
|
||||
limit
|
||||
})
|
||||
}
|
||||
|
||||
// The last-resort latch, and the only sweep that disables the switch. With
|
||||
// the arrival sweep above running it should never reach a row; one that
|
||||
// survives a day past dispatch means the rollback path itself is broken.
|
||||
async abortExpiredRegionalRehomes(limit = 100): Promise<number> {
|
||||
return await this.rollBackStalledRegionalRehomes({
|
||||
sweep: 'abort-expired-regional-rehomes',
|
||||
minimumAttemptAgeMs: REGIONAL_REHOME_MAX_REFRESH_MS,
|
||||
abortReason: 'max_refresh_expired',
|
||||
disableControl: true,
|
||||
limit
|
||||
})
|
||||
}
|
||||
|
||||
private async rollBackStalledRegionalRehomes(input: {
|
||||
sweep: string
|
||||
minimumAttemptAgeMs: number
|
||||
abortReason: RegionalRehomeAbortReason
|
||||
disableControl: boolean
|
||||
limit: number
|
||||
}): Promise<number> {
|
||||
const now = this.now()
|
||||
const dispatchedBefore = now - input.minimumAttemptAgeMs
|
||||
const quarantined = this.quarantinedRegionalRehomeAttemptIds(now)
|
||||
const exclusion = quarantined.length
|
||||
? ` AND attempt_id NOT IN (${quarantined.map(() => '?').join(', ')})`
|
||||
@@ -6257,7 +6418,7 @@ export class RelayAssignmentStore {
|
||||
WHERE completed_at IS NULL AND aborted_at IS NULL
|
||||
AND created_at <= ?${exclusion}
|
||||
ORDER BY created_at, attempt_id LIMIT ?`,
|
||||
[now - REGIONAL_REHOME_MAX_REFRESH_MS, ...quarantined, limit]
|
||||
[dispatchedBefore, ...quarantined, input.limit]
|
||||
)
|
||||
let aborted = 0
|
||||
let inventoryBusy = 0
|
||||
@@ -6294,7 +6455,7 @@ export class RelayAssignmentStore {
|
||||
!migration ||
|
||||
optionalInteger(attempt, 'completed_at') !== undefined ||
|
||||
optionalInteger(attempt, 'aborted_at') !== undefined ||
|
||||
integer(attempt, 'created_at') > now - REGIONAL_REHOME_MAX_REFRESH_MS ||
|
||||
integer(attempt, 'created_at') > dispatchedBefore ||
|
||||
optionalInteger(migration, 'completed_at') !== undefined ||
|
||||
optionalInteger(migration, 'aborted_at') !== undefined
|
||||
) {
|
||||
@@ -6318,6 +6479,7 @@ export class RelayAssignmentStore {
|
||||
integer(lease, 'expires_at') > now
|
||||
)
|
||||
if (targetActive) return false
|
||||
await this.lockControlConnectionReservations(transaction, identity, 'nowait')
|
||||
const cells = await this.lockCellInventory(transaction, 'nowait')
|
||||
const source = cells.find((cell) => text(cell, 'cell_id') === sourceCellId)
|
||||
const admission = await cellAdmissionStates(transaction)
|
||||
@@ -6362,16 +6524,22 @@ export class RelayAssignmentStore {
|
||||
[now, now, identity.userId, identity.relayHostId, assignmentEpoch]
|
||||
)
|
||||
await transaction.query(
|
||||
`UPDATE relay_region_rehome_attempts SET aborted_at = ?, updated_at = ?
|
||||
`UPDATE relay_region_rehome_attempts
|
||||
SET aborted_at = ?, abort_reason = ?, updated_at = ?
|
||||
WHERE attempt_id = ?`,
|
||||
[now, now, text(attempt, 'attempt_id')]
|
||||
)
|
||||
await transaction.query(
|
||||
`UPDATE relay_region_rehome_control
|
||||
SET generation = generation + 1, enabled = 0, updated_at = ?
|
||||
WHERE control_id = 'global' AND enabled = 1`,
|
||||
[now]
|
||||
[now, input.abortReason, now, text(attempt, 'attempt_id')]
|
||||
)
|
||||
// Only the last-resort latch turns the feature off. A host that closed
|
||||
// its laptop mid-move says nothing about whether rehoming is safe, and
|
||||
// one such row a day would otherwise disable the switch every day.
|
||||
if (input.disableControl) {
|
||||
await transaction.query(
|
||||
`UPDATE relay_region_rehome_control
|
||||
SET generation = generation + 1, enabled = 0, updated_at = ?
|
||||
WHERE control_id = 'global' AND enabled = 1`,
|
||||
[now]
|
||||
)
|
||||
}
|
||||
return true
|
||||
})
|
||||
this.regionalRehomeCandidateQuarantine.delete(attemptId)
|
||||
@@ -6381,7 +6549,7 @@ export class RelayAssignmentStore {
|
||||
}
|
||||
if (changed) aborted++
|
||||
}
|
||||
warnSweepCellInventoryBusy('abort-expired-regional-rehomes', inventoryBusy)
|
||||
warnSweepCellInventoryBusy(input.sweep, inventoryBusy)
|
||||
return aborted
|
||||
}
|
||||
|
||||
@@ -6484,7 +6652,10 @@ export class RelayAssignmentStore {
|
||||
]
|
||||
.map((activityId) => activityLeaseById(activityLeases, activityId))
|
||||
.filter((lease): lease is SqlRow => lease !== undefined)
|
||||
if (obsoleteLeases.length > 0) await this.lockCellInventory(transaction, 'nowait')
|
||||
if (obsoleteLeases.length > 0) {
|
||||
await this.lockControlConnectionReservations(transaction, identity, 'nowait')
|
||||
await this.lockCellInventory(transaction, 'nowait')
|
||||
}
|
||||
for (const lease of obsoleteLeases) {
|
||||
await this.removeActivityLease(transaction, identity, lease, now)
|
||||
}
|
||||
@@ -6502,6 +6673,7 @@ export class RelayAssignmentStore {
|
||||
)
|
||||
return true
|
||||
}
|
||||
await this.lockControlConnectionReservations(transaction, identity, 'nowait')
|
||||
const cells = await this.lockCellInventory(transaction, 'nowait')
|
||||
const sourceCellId = text(row, 'source_cell_id')
|
||||
const admissionRows = await transaction.query(
|
||||
@@ -6921,6 +7093,24 @@ export class RelayAssignmentStore {
|
||||
)
|
||||
}
|
||||
|
||||
// Tier 3 of the row lock order: a path that will take relay_cells and also
|
||||
// touch this host's reservations takes them here, before the cell rows. The
|
||||
// set is the host's own rows, so it is small and known before any placement
|
||||
// decision is read.
|
||||
private async lockControlConnectionReservations(
|
||||
database: RelayDatabase,
|
||||
identity: AssignmentIdentity,
|
||||
mode: CellInventoryLockMode = 'request'
|
||||
): Promise<void> {
|
||||
const { measureHoldMs: _sampled, ...wait } = cellInventoryLockOptions(mode)
|
||||
await database.queryLocked(
|
||||
`SELECT reservation_id FROM relay_control_connection_reservations
|
||||
WHERE user_id = ? AND relay_host_id = ? ORDER BY reservation_id ASC`,
|
||||
[identity.userId, identity.relayHostId],
|
||||
wait
|
||||
)
|
||||
}
|
||||
|
||||
// Unlocked on purpose: this only names the row to lock next, and the caller
|
||||
// re-checks the pin once the assignment row is held.
|
||||
private async pinnedCellId(
|
||||
@@ -7124,6 +7314,31 @@ export class RelayAssignmentStore {
|
||||
return rows.length === 1
|
||||
}
|
||||
|
||||
// Reports which of `cellIsLive`'s conditions failed, so the rejection log
|
||||
// separates an expected drain or boot from a cell whose readiness went out
|
||||
// from under its hosts.
|
||||
private async homeCellUnavailableCause(
|
||||
database: RelayDatabase,
|
||||
cellId: string,
|
||||
now: number
|
||||
): Promise<RelayHomeCellUnavailableCause> {
|
||||
const row = (
|
||||
await database.query(
|
||||
`SELECT cell.enabled, runtime.last_heartbeat_at
|
||||
FROM relay_cells cell
|
||||
LEFT JOIN relay_cell_runtime runtime ON runtime.cell_id = cell.cell_id
|
||||
WHERE cell.cell_id = ?`,
|
||||
[cellId]
|
||||
)
|
||||
)[0]
|
||||
if (!row) return 'booting'
|
||||
if (integer(row, 'enabled') === 0) return 'draining'
|
||||
const heartbeatAt = optionalInteger(row, 'last_heartbeat_at')
|
||||
if (heartbeatAt === undefined) return 'booting'
|
||||
// Readiness is all that is left: `cellIsLive` already refused this cell.
|
||||
return heartbeatAt <= now - this.heartbeatTtlMs ? 'unheard' : 'not_ready'
|
||||
}
|
||||
|
||||
private async cellHasActiveFence(cellId: string): Promise<boolean> {
|
||||
const rows = await this.database.query(
|
||||
`SELECT fence.cell_id FROM relay_cell_fences fence
|
||||
@@ -7551,6 +7766,9 @@ export class RelayAssignmentStore {
|
||||
await this.adjustActivityCount(database, identity, kind, -1, now, now)
|
||||
}
|
||||
|
||||
// Returns the request units this removal frees on `cellId`. The caller folds
|
||||
// them into the one conditional cell-row write it makes just before COMMIT,
|
||||
// so no statement here touches the fleet's most contended row.
|
||||
private async removeSupersededSameCellControls(
|
||||
database: RelayDatabase,
|
||||
identity: AssignmentIdentity,
|
||||
@@ -7558,14 +7776,14 @@ export class RelayAssignmentStore {
|
||||
cellId: string,
|
||||
retainedActivityId: string,
|
||||
now: number
|
||||
): Promise<void> {
|
||||
): Promise<number> {
|
||||
const superseded = leases.filter(
|
||||
(lease) =>
|
||||
activityKind(lease) === 'control' &&
|
||||
text(lease, 'cell_id') === cellId &&
|
||||
text(lease, 'activity_id') !== retainedActivityId
|
||||
)
|
||||
if (superseded.length === 0) return
|
||||
if (superseded.length === 0) return 0
|
||||
if (
|
||||
superseded.some(
|
||||
(lease) => integer(lease, 'request_units') !== ACTIVITY_REQUEST_UNITS.control
|
||||
@@ -7573,10 +7791,6 @@ export class RelayAssignmentStore {
|
||||
) {
|
||||
throw new Error('activity_lease_shape_mismatch')
|
||||
}
|
||||
// Why: this recomputes one cell's reservation from its leases, so only that
|
||||
// row needs to be held; the 23-row inventory lock here serialised every
|
||||
// desktop control rebind in the fleet behind every other one.
|
||||
const cellRow = (await this.lockCellRows(database, [cellId]))[0]
|
||||
await database.query(
|
||||
`DELETE FROM relay_assignment_activity_leases
|
||||
WHERE user_id = ? AND relay_host_id = ? AND activity_kind = 'control'
|
||||
@@ -7590,22 +7804,9 @@ export class RelayAssignmentStore {
|
||||
WHERE user_id = ? AND relay_host_id = ?`,
|
||||
[remainingControls, now, identity.userId, identity.relayHostId]
|
||||
)
|
||||
const cellUnitsRow = (
|
||||
await database.query(
|
||||
`SELECT COALESCE(SUM(request_units), 0) AS request_units
|
||||
FROM relay_assignment_activity_leases WHERE cell_id = ?`,
|
||||
[cellId]
|
||||
)
|
||||
)[0]!
|
||||
const cellUnits = integer(cellUnitsRow, 'request_units')
|
||||
if (!cellRow) throw new Error('assigned_cell_missing')
|
||||
if (cellUnits > integer(cellRow, 'capacity_requests')) {
|
||||
throw new Error('relay_capacity_exhausted')
|
||||
}
|
||||
await database.query(
|
||||
`UPDATE relay_cells SET reserved_requests = ?, updated_at = ? WHERE cell_id = ?`,
|
||||
[cellUnits, now, cellId]
|
||||
)
|
||||
// The shape check above proved every superseded lease holds exactly the
|
||||
// control unit, so the freed units are exact without re-summing the cell.
|
||||
return superseded.length * ACTIVITY_REQUEST_UNITS.control
|
||||
}
|
||||
|
||||
private async adjustActivityCount(
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { afterEach, describe, expect, it, vi, type MockInstance } from 'vitest'
|
||||
import { openRelayDatabaseAtBoot } from './boot-database-open.js'
|
||||
import type { RelayDatabase } from './database.js'
|
||||
|
||||
const input = { dataDir: '/tmp/orca-relay-boot', databaseUrl: 'postgres://relay@localhost/relay' }
|
||||
// The message the fleet actually saw: pg-pool reports the connect timeout with
|
||||
// no SQLSTATE, so the classifier has only this text to go on.
|
||||
const connectTimeout = (): Error => new Error('Connection terminated due to connection timeout')
|
||||
const database = {} as RelayDatabase
|
||||
|
||||
function loggedEvents(warn: MockInstance<typeof console.warn>): string[] {
|
||||
return warn.mock.calls.map((call) => String(JSON.parse(String(call[0])).event))
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('relay boot database open', () => {
|
||||
it('waits out a cold proxy instead of failing the boot', async () => {
|
||||
vi.useFakeTimers()
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const open = vi
|
||||
.fn<() => Promise<RelayDatabase>>()
|
||||
.mockRejectedValueOnce(connectTimeout())
|
||||
.mockRejectedValueOnce(connectTimeout())
|
||||
.mockResolvedValue(database)
|
||||
|
||||
const opening = openRelayDatabaseAtBoot(input, open)
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(await opening).toBe(database)
|
||||
expect(open).toHaveBeenCalledTimes(3)
|
||||
expect(open).toHaveBeenCalledWith(input)
|
||||
expect(loggedEvents(warn)).toEqual([
|
||||
'orca_relay_boot_database_retry',
|
||||
'orca_relay_boot_database_retry',
|
||||
'orca_relay_boot_database_recovered'
|
||||
])
|
||||
expect(JSON.parse(String(warn.mock.calls[0]?.[0]))).toMatchObject({
|
||||
attempt: 1,
|
||||
delayMs: expect.any(Number),
|
||||
code: 'unknown',
|
||||
connectionTimeout: true
|
||||
})
|
||||
})
|
||||
|
||||
it('fails the boot immediately when the database rejects the relay', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const denied = Object.assign(new Error('password authentication failed'), { code: '28P01' })
|
||||
const open = vi.fn<() => Promise<RelayDatabase>>().mockRejectedValue(denied)
|
||||
|
||||
await expect(openRelayDatabaseAtBoot(input, open)).rejects.toBe(denied)
|
||||
expect(open).toHaveBeenCalledTimes(1)
|
||||
expect(loggedEvents(warn)).toEqual(['orca_relay_boot_database_failed'])
|
||||
expect(JSON.parse(String(warn.mock.calls[0]?.[0]))).toMatchObject({
|
||||
attempts: 1,
|
||||
retryable: false,
|
||||
code: 'unknown'
|
||||
})
|
||||
})
|
||||
|
||||
// A retry re-runs the schema apply, which must never re-queue a boot DDL
|
||||
// behind the writers that beat it; the request path treats these as transient.
|
||||
it.each(['55P03', '57014', '53300'])(
|
||||
'refuses to re-queue the schema apply after SQLSTATE %s',
|
||||
async (code) => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const contention = Object.assign(new Error('lock unavailable'), { code })
|
||||
const open = vi.fn<() => Promise<RelayDatabase>>().mockRejectedValue(contention)
|
||||
|
||||
await expect(openRelayDatabaseAtBoot(input, open)).rejects.toBe(contention)
|
||||
expect(open).toHaveBeenCalledTimes(1)
|
||||
expect(loggedEvents(warn)).toEqual(['orca_relay_boot_database_failed'])
|
||||
expect(JSON.parse(String(warn.mock.calls[0]?.[0]))).toMatchObject({
|
||||
attempts: 1,
|
||||
retryable: false,
|
||||
code
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it('waits out a connection failure the driver does report a SQLSTATE for', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const unreachable = Object.assign(new Error('connection refused'), { code: '08006' })
|
||||
const open = vi
|
||||
.fn<() => Promise<RelayDatabase>>()
|
||||
.mockRejectedValueOnce(unreachable)
|
||||
.mockResolvedValue(database)
|
||||
|
||||
const opening = openRelayDatabaseAtBoot(input, open)
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
expect(await opening).toBe(database)
|
||||
expect(open).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('gives up once the retry budget is spent', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(0)
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0)
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const failure = connectTimeout()
|
||||
const open = vi.fn<() => Promise<RelayDatabase>>().mockRejectedValue(failure)
|
||||
|
||||
const opening = openRelayDatabaseAtBoot(input, open)
|
||||
const rejection = expect(opening).rejects.toBe(failure)
|
||||
await vi.runAllTimersAsync()
|
||||
await rejection
|
||||
|
||||
expect(Date.now()).toBeLessThanOrEqual(45_000)
|
||||
expect(open.mock.calls.length).toBeGreaterThan(1)
|
||||
const events = loggedEvents(warn)
|
||||
expect(events.at(-1)).toBe('orca_relay_boot_database_failed')
|
||||
expect(events.filter((event) => event === 'orca_relay_boot_database_retry')).toHaveLength(
|
||||
open.mock.calls.length - 1
|
||||
)
|
||||
expect(JSON.parse(String(warn.mock.calls.at(-1)?.[0]))).toMatchObject({
|
||||
attempts: open.mock.calls.length,
|
||||
retryable: true,
|
||||
connectionTimeout: true
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
import { openRelayDatabase, type RelayDatabase, type RelayDatabaseOpenInput } from './database.js'
|
||||
import { retryTransientDatabaseStartup } from './database-startup-retry.js'
|
||||
import {
|
||||
isPostgresPoolConnectFailure,
|
||||
isPostgresPoolConnectTimeout
|
||||
} from './postgres-pool-pressure.js'
|
||||
import { postgresErrorCodeCategory } from './postgres-query-failure.js'
|
||||
|
||||
// Only a failure to reach Postgres at all. A retry here re-runs the schema
|
||||
// apply, and applyPostgresSchema refuses to repeat a DDL lock timeout on
|
||||
// purpose: relation locks are granted in queue order, so a repeat parks every
|
||||
// writer behind the same statement again. 55P03, 57014 and 53300 therefore stay
|
||||
// terminal at boot even though the request path calls them transient.
|
||||
function isBootDatabaseUnreachable(error: unknown): boolean {
|
||||
const code = postgresErrorCodeCategory(error)
|
||||
return isPostgresPoolConnectFailure(error) || code === '08001' || code === '08006'
|
||||
}
|
||||
|
||||
// A cell boots beside a cloud-sql-proxy that is itself still dialling, so the
|
||||
// first pool acquire can outrun the 2s connect timeout that protects the
|
||||
// request path. The window is longer than a proxy cold start and shorter than
|
||||
// the restart loop it replaces.
|
||||
const BOOT_OPEN_RETRY = {
|
||||
attempts: 20,
|
||||
windowMs: 45_000,
|
||||
baseDelayMs: 250,
|
||||
maxDelayMs: 4_000,
|
||||
jitterMs: 250,
|
||||
isRetryable: isBootDatabaseUnreachable
|
||||
}
|
||||
|
||||
function bootDatabaseErrorFields(error: unknown): Record<string, unknown> {
|
||||
return {
|
||||
code: postgresErrorCodeCategory(error),
|
||||
connectionTimeout: isPostgresPoolConnectTimeout(error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function openRelayDatabaseAtBoot(
|
||||
input: RelayDatabaseOpenInput,
|
||||
open: (input: RelayDatabaseOpenInput) => Promise<RelayDatabase> = openRelayDatabase
|
||||
): Promise<RelayDatabase> {
|
||||
return await retryTransientDatabaseStartup(
|
||||
async () => await open(input),
|
||||
BOOT_OPEN_RETRY,
|
||||
{
|
||||
onRetry: ({ attempt, delayMs, error }) =>
|
||||
console.warn(
|
||||
JSON.stringify({
|
||||
event: 'orca_relay_boot_database_retry',
|
||||
attempt,
|
||||
delayMs,
|
||||
...bootDatabaseErrorFields(error)
|
||||
})
|
||||
),
|
||||
onRecovered: ({ attempts }) =>
|
||||
console.warn(
|
||||
JSON.stringify({ event: 'orca_relay_boot_database_recovered', attempts })
|
||||
),
|
||||
onGaveUp: ({ attempts, error, retryable }) =>
|
||||
console.warn(
|
||||
JSON.stringify({
|
||||
event: 'orca_relay_boot_database_failed',
|
||||
attempts,
|
||||
retryable,
|
||||
...bootDatabaseErrorFields(error)
|
||||
})
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -1,14 +1,19 @@
|
||||
import type { RelayAssignmentStore } from './assignment-store.js'
|
||||
import type { RelayConfig } from './config.js'
|
||||
import { isRelayDatabaseTransientError } from './database.js'
|
||||
import { retryTransientDatabaseStartup } from './database-startup-retry.js'
|
||||
|
||||
type CellAdmissionStartupConfig = Pick<RelayConfig, 'role' | 'cells'>
|
||||
type CellAdmissionStore = Pick<RelayAssignmentStore, 'reconcileCellsAtStartup'>
|
||||
|
||||
const STARTUP_RECONCILE_ATTEMPTS = 20
|
||||
const STARTUP_RECONCILE_RETRY_WINDOW_MS = 45_000
|
||||
const STARTUP_RECONCILE_RETRY_BASE_MS = 250
|
||||
const STARTUP_RECONCILE_RETRY_JITTER_MS = 250
|
||||
const STARTUP_RECONCILE_RETRY = {
|
||||
attempts: 20,
|
||||
windowMs: 45_000,
|
||||
// Flat: the contention this waits out is another director's schema lock, which
|
||||
// clears on its own schedule rather than easing as the wait grows.
|
||||
baseDelayMs: 250,
|
||||
maxDelayMs: 250,
|
||||
jitterMs: 250
|
||||
}
|
||||
|
||||
export function roleOwnsAssignmentMaintenance(role: RelayConfig['role']): boolean {
|
||||
// Cell workers share the database but the director is the sole authority
|
||||
@@ -23,34 +28,21 @@ 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
|
||||
const retryDeadline = Date.now() + STARTUP_RECONCILE_RETRY_WINDOW_MS
|
||||
for (let attempt = 1; attempt <= STARTUP_RECONCILE_ATTEMPTS; attempt += 1) {
|
||||
try {
|
||||
await assignments.reconcileCellsAtStartup(config.cells)
|
||||
if (attempt > 1) {
|
||||
await retryTransientDatabaseStartup(
|
||||
async () => await assignments.reconcileCellsAtStartup(config.cells),
|
||||
STARTUP_RECONCILE_RETRY,
|
||||
{
|
||||
onRecovered: ({ attempts }) =>
|
||||
console.warn(
|
||||
JSON.stringify({ event: 'orca_relay_startup_reconcile_recovered', attempts: attempt })
|
||||
)
|
||||
}
|
||||
return
|
||||
} catch (error) {
|
||||
const remainingMs = retryDeadline - Date.now()
|
||||
if (
|
||||
attempt === STARTUP_RECONCILE_ATTEMPTS ||
|
||||
remainingMs <= 0 ||
|
||||
!isRelayDatabaseTransientError(error)
|
||||
) {
|
||||
if (isRelayDatabaseTransientError(error)) {
|
||||
JSON.stringify({ event: 'orca_relay_startup_reconcile_recovered', attempts })
|
||||
),
|
||||
onGaveUp: ({ attempts, retryable }) => {
|
||||
if (retryable) {
|
||||
console.warn(
|
||||
JSON.stringify({ event: 'orca_relay_startup_reconcile_exhausted', attempts: attempt })
|
||||
JSON.stringify({ event: 'orca_relay_startup_reconcile_exhausted', attempts })
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
const delayMs =
|
||||
STARTUP_RECONCILE_RETRY_BASE_MS +
|
||||
Math.floor(Math.random() * (STARTUP_RECONCILE_RETRY_JITTER_MS + 1))
|
||||
await new Promise((resolve) => setTimeout(resolve, Math.min(delayMs, remainingMs)))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -32,7 +32,9 @@ const CENSUS: CensusEntry[] = [
|
||||
// only the one or two cell rows they touch, in cell_id order (lockCellRows),
|
||||
// so they cannot cycle with placement's ordered inventory lock, and the
|
||||
// 23-row lock there had serialised every reconnect in the fleet behind every
|
||||
// other one.
|
||||
// other one. The control accept path went one step further and takes no cell
|
||||
// read lock at all: its single conditional write is the last statement before
|
||||
// COMMIT.
|
||||
{ method: 'startEvacuation', mode: 'request', reach: 'request' },
|
||||
{ method: 'completeEvacuationFromDeadSourceOnce', mode: 'request', reach: 'request' },
|
||||
{ method: 'completeEvacuationFromDeadSourceOnce', mode: 'nowait', reach: 'request' },
|
||||
@@ -45,7 +47,9 @@ const CENSUS: CensusEntry[] = [
|
||||
{ method: 'rebalanceDormant', mode: 'request', reach: 'request' },
|
||||
{ method: 'startRegionalRehomeCandidate', mode: 'nowait', reach: 'request' },
|
||||
{ method: 'completeRegionalRehomeCandidate', mode: 'nowait', reach: 'sweep' },
|
||||
{ method: 'abortExpiredRegionalRehomes', mode: 'nowait', reach: 'sweep' },
|
||||
// Both regional-rehome abort sweeps share this rollback; only the 24-hour
|
||||
// one also disables the durable switch.
|
||||
{ method: 'rollBackStalledRegionalRehomes', mode: 'nowait', reach: 'sweep' },
|
||||
{ method: 'abortExpiredEvacuations', mode: 'nowait', reach: 'sweep' },
|
||||
{ method: 'abortExpiredEvacuations', mode: 'nowait', reach: 'sweep' },
|
||||
{ method: 'releaseExpiredActivityLeases', mode: 'nowait', reach: 'sweep' },
|
||||
@@ -172,6 +176,63 @@ function readCallSites(): { method: string; mode: CensusMode }[] {
|
||||
return sites
|
||||
}
|
||||
|
||||
|
||||
// Tier 3 and tier 4 of the row lock order documented in assignment-store.ts. A
|
||||
// transaction that takes relay_cells before this host's reservation rows can
|
||||
// cycle with one that takes them the other way round, and PostgreSQL resolves
|
||||
// that as a 40P01 during exactly the drain and rehome waves these paths exist
|
||||
// to run. The cell row is the one every host on a cell shares, so it is the
|
||||
// lock that must be taken last, which fixes the direction for everyone else.
|
||||
const CELL_LOCK_CALL =
|
||||
/this\.(?:lockCellInventory|lockGeneralCellInventory|lockCellRows|adjustCellReservationAtomically|adjustCellReservation)\(|UPDATE relay_cells/
|
||||
const RESERVATION_LOCK_CALL =
|
||||
/this\.(?:lockControlConnectionReservations|insertControlConnectionReservation|claimControlConnectionReservation|releaseSupersededControlConnectionReservations)\(|(?:UPDATE|INTO|DELETE FROM)\s+relay_control_connection_reservations/
|
||||
|
||||
// The lock helpers themselves, plus the one reporting query that reads both
|
||||
// tables without locking either.
|
||||
const ROW_LOCK_ORDER_EXEMPT = [
|
||||
'lockCellInventory',
|
||||
'lockGeneralCellInventory',
|
||||
'lockCellRows',
|
||||
'lockControlConnectionReservations',
|
||||
'adjustCellReservation',
|
||||
'adjustCellReservationAtomically',
|
||||
'insertControlConnectionReservation',
|
||||
'claimControlConnectionReservation',
|
||||
'releaseSupersededControlConnectionReservations',
|
||||
'cellDeploymentStatus'
|
||||
]
|
||||
|
||||
function methodSpans(lines: string[]): { name: string; start: number; end: number }[] {
|
||||
const starts: { name: string; start: number }[] = []
|
||||
lines.forEach((line, index) => {
|
||||
const declaration = DECLARATION.exec(line)
|
||||
if (declaration) starts.push({ name: declaration[1]!, start: index })
|
||||
})
|
||||
return starts.map((entry, index) => ({
|
||||
...entry,
|
||||
end: starts[index + 1]?.start ?? lines.length
|
||||
}))
|
||||
}
|
||||
|
||||
function pathsTakingCellsBeforeReservations(lines: string[]): string[] {
|
||||
const offending: string[] = []
|
||||
for (const span of methodSpans(lines)) {
|
||||
if (ROW_LOCK_ORDER_EXEMPT.includes(span.name)) continue
|
||||
let cell = Number.POSITIVE_INFINITY
|
||||
let reservation = Number.POSITIVE_INFINITY
|
||||
for (let index = span.start; index < span.end; index++) {
|
||||
const line = lines[index]!
|
||||
if (CELL_LOCK_CALL.test(line)) cell = Math.min(cell, index)
|
||||
if (RESERVATION_LOCK_CALL.test(line)) reservation = Math.min(reservation, index)
|
||||
}
|
||||
if (cell < reservation && reservation !== Number.POSITIVE_INFINITY) {
|
||||
offending.push(span.name)
|
||||
}
|
||||
}
|
||||
return offending
|
||||
}
|
||||
|
||||
describe('cell inventory lock call-site census', () => {
|
||||
it('classifies every call site exactly as recorded', () => {
|
||||
expect(readCallSites()).toEqual(CENSUS.map(({ method, mode }) => ({ method, mode })))
|
||||
@@ -213,6 +274,10 @@ describe('cell inventory lock call-site census', () => {
|
||||
expect(rawSites).toEqual(INLINE_CELL_LOCK_SITES)
|
||||
})
|
||||
|
||||
it('takes the host reservation rows before the shared cell row everywhere', () => {
|
||||
expect(pathsTakingCellsBeforeReservations(storeSource())).toEqual([])
|
||||
})
|
||||
|
||||
it('leaves no call site taking the inventory without naming a mode', () => {
|
||||
const source = readFileSync(new URL('./assignment-store.ts', import.meta.url), 'utf8')
|
||||
const unclassified = source
|
||||
|
||||
@@ -8,6 +8,14 @@ const fakes = vi.hoisted(() => ({
|
||||
return { rows: [], rowCount: 0 }
|
||||
}),
|
||||
release: vi.fn(),
|
||||
// A real pooled client is an EventEmitter, and the acquire path attaches an
|
||||
// `error` listener to it before handing it to the caller.
|
||||
client: () => ({
|
||||
query: fakes.query,
|
||||
release: fakes.release,
|
||||
on: vi.fn(),
|
||||
removeListener: vi.fn()
|
||||
}),
|
||||
end: vi.fn(async () => undefined)
|
||||
}))
|
||||
|
||||
@@ -19,7 +27,7 @@ vi.mock('pg', () => ({
|
||||
waitingCount = 0
|
||||
end = fakes.end
|
||||
on = vi.fn()
|
||||
connect = vi.fn(async () => ({ query: fakes.query, release: fakes.release }))
|
||||
connect = vi.fn(async () => fakes.client())
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { RelayAssignmentStore } from './assignment-store.js'
|
||||
import { openRelayDatabase, type RelayDatabase } from './database.js'
|
||||
|
||||
const databaseUrl = process.env.ORCA_RELAY_TEST_POSTGRES_URL
|
||||
const describePostgres = databaseUrl ? describe : describe.skip
|
||||
|
||||
const cell = {
|
||||
id: 'accept-lock-postgres',
|
||||
url: 'https://accept-lock-postgres.example.com',
|
||||
capacityRequests: 2,
|
||||
connectionHardCap: 600 as const,
|
||||
connectionUnobservedBound: 50
|
||||
}
|
||||
const userId = 'accept-lock-postgres-user'
|
||||
const first = { userId, relayHostId: 'acceptlockhost01' }
|
||||
const second = { userId, relayHostId: 'acceptlockhost02' }
|
||||
|
||||
// Why: the accept path now enforces the capacity ceiling inside its single
|
||||
// conditional cell-row write instead of behind a SELECT ... FOR UPDATE it held
|
||||
// for the rest of the transaction. Two accepts reaching for the same last slot
|
||||
// are what would expose a lost update if that check were no longer atomic.
|
||||
describePostgres('PostgreSQL control accept without a held cell row', () => {
|
||||
const databases: RelayDatabase[] = []
|
||||
|
||||
beforeAll(async () => {
|
||||
databases.push(
|
||||
await openRelayDatabase({ databaseUrl, dataDir: '' }),
|
||||
await openRelayDatabase({ databaseUrl, dataDir: '' })
|
||||
)
|
||||
})
|
||||
|
||||
async function removeTestRows(database: RelayDatabase): Promise<void> {
|
||||
await database.query(
|
||||
`DELETE FROM relay_control_connection_reservations WHERE user_id = ?`,
|
||||
[userId]
|
||||
)
|
||||
for (const table of [
|
||||
'relay_control_capabilities',
|
||||
'relay_assignment_activity_leases',
|
||||
'relay_post_drain_migration_pins',
|
||||
'relay_assignment_migration_incarnations',
|
||||
'relay_assignment_migrations',
|
||||
'relay_assignments'
|
||||
]) {
|
||||
await database.query(`DELETE FROM ${table} WHERE user_id = ?`, [userId])
|
||||
}
|
||||
for (const table of [
|
||||
'relay_cell_connection_snapshots',
|
||||
'relay_cell_connection_runtime',
|
||||
'relay_cell_connection_limits',
|
||||
'relay_cell_runtime',
|
||||
'relay_cells'
|
||||
]) {
|
||||
await database.query(`DELETE FROM ${table} WHERE cell_id = ?`, [cell.id])
|
||||
}
|
||||
}
|
||||
|
||||
afterAll(async () => {
|
||||
if (databases[0]) await removeTestRows(databases[0])
|
||||
for (const connection of databases) await connection.close()
|
||||
})
|
||||
|
||||
it('lets exactly one of two racing accepts take the last capacity slot', async () => {
|
||||
await removeTestRows(databases[0]!)
|
||||
const stores = databases.map((database) => new RelayAssignmentStore(database, () => 100))
|
||||
await prepareCell(stores[0]!)
|
||||
|
||||
// Both hosts hold a grant, then drop the control the grant reserved, so the
|
||||
// cell has exactly one free slot and two accepts that each want it.
|
||||
const epochs = new Map<string, number>()
|
||||
for (const identity of [first, second]) {
|
||||
const assignment = await stores[0]!.assign(identity)
|
||||
epochs.set(identity.relayHostId, assignment.assignmentEpoch)
|
||||
const control = await stores[0]!.activateControl(identity, {
|
||||
cellId: cell.id,
|
||||
assignmentEpoch: assignment.assignmentEpoch,
|
||||
generation: 1
|
||||
})
|
||||
await stores[0]!.releaseActivity(identity, control)
|
||||
}
|
||||
await databases[0]!.query(
|
||||
`UPDATE relay_cells SET reserved_requests = ? WHERE cell_id = ?`,
|
||||
[cell.capacityRequests - 1, cell.id]
|
||||
)
|
||||
await databases[0]!.query(
|
||||
`INSERT INTO relay_assignment_activity_leases
|
||||
(user_id, relay_host_id, activity_id, activity_kind, cell_id,
|
||||
request_units, expires_at, updated_at)
|
||||
VALUES (?, ?, 'splice:ballast', 'splice', ?, 1, 90100, 100)`,
|
||||
[userId, 'acceptlockhost03', cell.id]
|
||||
)
|
||||
|
||||
const outcomes = await Promise.allSettled([
|
||||
stores[0]!.activateControl(first, {
|
||||
cellId: cell.id,
|
||||
assignmentEpoch: epochs.get(first.relayHostId)!,
|
||||
generation: 2
|
||||
}),
|
||||
stores[1]!.activateControl(second, {
|
||||
cellId: cell.id,
|
||||
assignmentEpoch: epochs.get(second.relayHostId)!,
|
||||
generation: 2
|
||||
})
|
||||
])
|
||||
|
||||
expect(outcomes.filter((outcome) => outcome.status === 'fulfilled')).toHaveLength(1)
|
||||
const rejection = outcomes.find((outcome) => outcome.status === 'rejected')
|
||||
expect(String((rejection as PromiseRejectedResult).reason)).toContain(
|
||||
'relay_capacity_exhausted'
|
||||
)
|
||||
const cells = await databases[0]!.query(
|
||||
`SELECT reserved_requests FROM relay_cells WHERE cell_id = ?`,
|
||||
[cell.id]
|
||||
)
|
||||
expect(Number(cells[0]!.reserved_requests)).toBe(cell.capacityRequests)
|
||||
const units = await databases[0]!.query(
|
||||
`SELECT COALESCE(SUM(request_units), 0) AS units
|
||||
FROM relay_assignment_activity_leases WHERE cell_id = ?`,
|
||||
[cell.id]
|
||||
)
|
||||
expect(Number(units[0]!.units)).toBe(cell.capacityRequests)
|
||||
}, 20_000)
|
||||
|
||||
it('rebinds a control while another connection holds the cell row', async () => {
|
||||
await removeTestRows(databases[0]!)
|
||||
const store = new RelayAssignmentStore(databases[0]!, () => 100)
|
||||
await prepareCell(store)
|
||||
const assignment = await store.assign(first)
|
||||
await store.activateControl(first, {
|
||||
cellId: cell.id,
|
||||
assignmentEpoch: assignment.assignmentEpoch,
|
||||
generation: 1
|
||||
})
|
||||
|
||||
let release!: () => void
|
||||
const released = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
})
|
||||
let held!: () => void
|
||||
const heldPromise = new Promise<void>((resolve) => {
|
||||
held = resolve
|
||||
})
|
||||
const holder = databases[1]!.transaction(async (transaction) => {
|
||||
await transaction.queryLocked(`SELECT * FROM relay_cells WHERE cell_id = ?`, [cell.id])
|
||||
held()
|
||||
await released
|
||||
})
|
||||
await heldPromise
|
||||
|
||||
// Retiring generation 1 and installing generation 2 leaves the cell's
|
||||
// reservation where it was, so the accept has no reason to wait on the row
|
||||
// at all. Reading it up front is what used to make it wait, and then fail
|
||||
// at the request-path lock bound.
|
||||
try {
|
||||
await expect(
|
||||
store.activateControl(first, {
|
||||
cellId: cell.id,
|
||||
assignmentEpoch: assignment.assignmentEpoch,
|
||||
generation: 2
|
||||
})
|
||||
).resolves.toBe(`control:${cell.id}:2`)
|
||||
} finally {
|
||||
release()
|
||||
await holder
|
||||
}
|
||||
|
||||
const cells = await databases[0]!.query(
|
||||
`SELECT reserved_requests FROM relay_cells WHERE cell_id = ?`,
|
||||
[cell.id]
|
||||
)
|
||||
const units = await databases[0]!.query(
|
||||
`SELECT COALESCE(SUM(request_units), 0) AS units
|
||||
FROM relay_assignment_activity_leases WHERE cell_id = ?`,
|
||||
[cell.id]
|
||||
)
|
||||
expect(Number(cells[0]!.reserved_requests)).toBe(Number(units[0]!.units))
|
||||
}, 20_000)
|
||||
|
||||
async function prepareCell(store: RelayAssignmentStore): Promise<void> {
|
||||
await store.reconcileCells([cell])
|
||||
await store.recordCellHeartbeat({
|
||||
cellId: cell.id,
|
||||
cellUrl: cell.url,
|
||||
cellIncarnation: '11111111-1111-4111-8111-111111111111',
|
||||
startedAt: 50,
|
||||
ready: true,
|
||||
observedRequests: 0,
|
||||
totalConnections: 0,
|
||||
inFlightConnections: 0,
|
||||
reservedConnectionUnits: 0,
|
||||
enforcedConnectionUnits: 0,
|
||||
connectionInclusionWatermark: 1,
|
||||
connectionHardCap: 600,
|
||||
connectionUnobservedBound: 50
|
||||
})
|
||||
}
|
||||
|
||||
})
|
||||
@@ -0,0 +1,148 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import type { RelayCellConfig } from './config.js'
|
||||
import { RelayAssignmentStore } from './assignment-store.js'
|
||||
import {
|
||||
openInMemoryRelayDatabase,
|
||||
type RelayDatabase,
|
||||
type RelayLockOptions,
|
||||
type RelayTransactionOptions,
|
||||
type SqlRow
|
||||
} from './database.js'
|
||||
|
||||
const CELL: RelayCellConfig = {
|
||||
id: 'accept-cell-a',
|
||||
url: 'https://accept-a.example.com',
|
||||
capacityRequests: 2
|
||||
}
|
||||
const host = { userId: 'accept-user', relayHostId: 'acceptho00000001' }
|
||||
const second = { userId: 'accept-user', relayHostId: 'acceptho00000002' }
|
||||
const third = { userId: 'accept-user', relayHostId: 'acceptho00000003' }
|
||||
|
||||
type Statement = { sql: string; locked: boolean }
|
||||
|
||||
// Records the statements a transaction issues, in order, so what the accept
|
||||
// path does with the shared cell row can be asserted rather than described.
|
||||
class RecordingDatabase implements RelayDatabase {
|
||||
constructor(
|
||||
private readonly inner: RelayDatabase,
|
||||
readonly statements: Statement[] = []
|
||||
) {}
|
||||
|
||||
async query(sql: string, params: unknown[] = []): Promise<SqlRow[]> {
|
||||
this.statements.push({ sql, locked: false })
|
||||
return await this.inner.query(sql, params)
|
||||
}
|
||||
|
||||
async queryLocked(
|
||||
sql: string,
|
||||
params: unknown[] = [],
|
||||
options: RelayLockOptions = {}
|
||||
): Promise<SqlRow[]> {
|
||||
this.statements.push({ sql, locked: true })
|
||||
return await this.inner.queryLocked(sql, params, options)
|
||||
}
|
||||
|
||||
async transaction<T>(
|
||||
operation: (transaction: RelayDatabase) => Promise<T>,
|
||||
options: RelayTransactionOptions = {}
|
||||
): Promise<T> {
|
||||
return await this.inner.transaction(
|
||||
async (transaction) =>
|
||||
await operation(new RecordingDatabase(transaction, this.statements)),
|
||||
options
|
||||
)
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.inner.close()
|
||||
}
|
||||
}
|
||||
|
||||
describe('control accept cell-row lock span', () => {
|
||||
let recorder: RecordingDatabase
|
||||
let store: RelayAssignmentStore
|
||||
let assignmentEpoch: number
|
||||
|
||||
beforeEach(async () => {
|
||||
recorder = new RecordingDatabase(await openInMemoryRelayDatabase())
|
||||
store = new RelayAssignmentStore(recorder, () => 100)
|
||||
await store.reconcileCells([CELL])
|
||||
assignmentEpoch = (await store.assign(host)).assignmentEpoch
|
||||
})
|
||||
|
||||
async function accept(generation: number): Promise<string> {
|
||||
recorder.statements.length = 0
|
||||
return await store.activateControl(host, {
|
||||
cellId: CELL.id,
|
||||
assignmentEpoch,
|
||||
generation
|
||||
})
|
||||
}
|
||||
|
||||
function cellStatements(): Statement[] {
|
||||
return recorder.statements.filter((statement) => /relay_cells/.test(statement.sql))
|
||||
}
|
||||
|
||||
async function reservedRequests(): Promise<number> {
|
||||
const row = (
|
||||
await recorder.query(`SELECT reserved_requests FROM relay_cells WHERE cell_id = ?`, [
|
||||
CELL.id
|
||||
])
|
||||
)[0]!
|
||||
return Number(row.reserved_requests)
|
||||
}
|
||||
|
||||
async function cellLeaseUnits(): Promise<number> {
|
||||
const row = (
|
||||
await recorder.query(
|
||||
`SELECT COALESCE(SUM(request_units), 0) AS units
|
||||
FROM relay_assignment_activity_leases WHERE cell_id = ?`,
|
||||
[CELL.id]
|
||||
)
|
||||
)[0]!
|
||||
return Number(row.units)
|
||||
}
|
||||
|
||||
it('writes the shared cell row once, last, and never takes it as a read lock', async () => {
|
||||
const control = await accept(1)
|
||||
await store.releaseActivity(host, control)
|
||||
|
||||
await accept(2)
|
||||
|
||||
const cells = cellStatements()
|
||||
expect(cells.map((statement) => statement.locked)).toEqual([false])
|
||||
expect(cells[0]!.sql).toContain('RETURNING cell_id')
|
||||
// The contended row is written by the last statement of the transaction, so
|
||||
// its write lock is held across the commit alone, not the whole accept.
|
||||
expect(recorder.statements.at(-1)).toBe(cells[0])
|
||||
})
|
||||
|
||||
it('leaves the cell row untouched when a rebind retires and installs one control', async () => {
|
||||
await accept(1)
|
||||
|
||||
await accept(2)
|
||||
|
||||
expect(cellStatements()).toEqual([])
|
||||
expect(await reservedRequests()).toBe(1)
|
||||
expect(await cellLeaseUnits()).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps the reservation equal to the cell lease units across repeated rebinds', async () => {
|
||||
for (const generation of [1, 2, 3, 4, 5]) await accept(generation)
|
||||
|
||||
expect(await reservedRequests()).toBe(await cellLeaseUnits())
|
||||
expect(await reservedRequests()).toBe(1)
|
||||
})
|
||||
|
||||
it('still refuses an accept that would exceed the cell capacity', async () => {
|
||||
const control = await accept(1)
|
||||
await store.assign(second)
|
||||
await store.releaseActivity(host, control)
|
||||
await store.assign(third)
|
||||
expect(await reservedRequests()).toBe(CELL.capacityRequests)
|
||||
|
||||
await expect(accept(2)).rejects.toThrow('relay_capacity_exhausted')
|
||||
expect(await reservedRequests()).toBe(CELL.capacityRequests)
|
||||
expect(await cellLeaseUnits()).toBe(CELL.capacityRequests)
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,14 @@ const fakes = vi.hoisted(() => ({
|
||||
lifecycle: [] as string[],
|
||||
query: vi.fn(async (_sql: string) => ({ rows: [], rowCount: 0 })),
|
||||
release: vi.fn(),
|
||||
// A real pooled client is an EventEmitter, and the acquire path attaches an
|
||||
// `error` listener to it before handing it to the caller.
|
||||
client: () => ({
|
||||
query: fakes.query,
|
||||
release: fakes.release,
|
||||
on: vi.fn(),
|
||||
removeListener: vi.fn()
|
||||
}),
|
||||
end: vi.fn(async () => undefined)
|
||||
}))
|
||||
|
||||
@@ -18,7 +26,7 @@ vi.mock('pg', () => ({
|
||||
idleCount = 1
|
||||
waitingCount = 0
|
||||
on = vi.fn()
|
||||
connect = vi.fn(async () => ({ query: fakes.query, release: fakes.release }))
|
||||
connect = vi.fn(async () => fakes.client())
|
||||
private readonly label: string
|
||||
|
||||
constructor(config: Record<string, unknown>) {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { isRelayDatabaseTransientError } from './database.js'
|
||||
|
||||
export type DatabaseStartupRetryPolicy = {
|
||||
attempts: number
|
||||
windowMs: number
|
||||
baseDelayMs: number
|
||||
maxDelayMs: number
|
||||
jitterMs: number
|
||||
// Which failures this particular startup step may repeat. Not every caller can
|
||||
// repeat everything the request path calls transient: what the retry re-runs
|
||||
// decides that, so the call site owns it.
|
||||
isRetryable?: (error: unknown) => boolean
|
||||
}
|
||||
|
||||
export type DatabaseStartupRetryObserver = {
|
||||
onRetry?: (event: { attempt: number; delayMs: number; error: unknown }) => void
|
||||
onRecovered?: (event: { attempts: number }) => void
|
||||
onGaveUp?: (event: { attempts: number; error: unknown; retryable: boolean }) => void
|
||||
}
|
||||
|
||||
function retryDelayMs(policy: DatabaseStartupRetryPolicy, attempt: number): number {
|
||||
const backoffMs = Math.min(policy.baseDelayMs * 2 ** (attempt - 1), policy.maxDelayMs)
|
||||
return backoffMs + Math.floor(Math.random() * (policy.jitterMs + 1))
|
||||
}
|
||||
|
||||
// Startup work that a cold dependency - a proxy sidecar that just started, a
|
||||
// database still accepting the fleet back - can fail once and serve a moment
|
||||
// later. The wall-clock window, not the attempt count, is the real bound.
|
||||
export async function retryTransientDatabaseStartup<T>(
|
||||
operation: () => Promise<T>,
|
||||
policy: DatabaseStartupRetryPolicy,
|
||||
observer: DatabaseStartupRetryObserver = {}
|
||||
): Promise<T> {
|
||||
const retryDeadline = Date.now() + policy.windowMs
|
||||
for (let attempt = 1; ; attempt += 1) {
|
||||
try {
|
||||
const result = await operation()
|
||||
if (attempt > 1) observer.onRecovered?.({ attempts: attempt })
|
||||
return result
|
||||
} catch (error) {
|
||||
const remainingMs = retryDeadline - Date.now()
|
||||
const retryable = (policy.isRetryable ?? isRelayDatabaseTransientError)(error)
|
||||
if (attempt === policy.attempts || remainingMs <= 0 || !retryable) {
|
||||
observer.onGaveUp?.({ attempts: attempt, error, retryable })
|
||||
throw error
|
||||
}
|
||||
const delayMs = Math.min(retryDelayMs(policy, attempt), remainingMs)
|
||||
observer.onRetry?.({ attempt, delayMs, error })
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -297,6 +297,7 @@ CREATE TABLE IF NOT EXISTS relay_region_rehome_attempts (
|
||||
),
|
||||
completed_at BIGINT,
|
||||
aborted_at BIGINT,
|
||||
abort_reason TEXT,
|
||||
created_at BIGINT NOT NULL,
|
||||
updated_at BIGINT NOT NULL,
|
||||
UNIQUE (user_id, relay_host_id, assignment_epoch)
|
||||
@@ -676,6 +677,9 @@ export const POSTGRES_SCHEMA_MIGRATIONS = [
|
||||
DEFAULT ${REGIONAL_REHOME_DEFAULT_HOST_COOLDOWN_MS}`,
|
||||
`ALTER TABLE relay_control_capabilities ADD COLUMN IF NOT EXISTS idle_regional_rehome BIGINT NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE relay_region_rehome_attempts ADD COLUMN IF NOT EXISTS source_generation BIGINT NOT NULL DEFAULT 0`,
|
||||
// Nullable with no default, so the rewrite is catalog-only; every row
|
||||
// aborted before this column existed reads as an unattributed abort.
|
||||
`ALTER TABLE relay_region_rehome_attempts ADD COLUMN IF NOT EXISTS abort_reason TEXT`,
|
||||
// Dropped, not created: see the comment on relay_assignment_activity_leases. Deferrable because
|
||||
// this is the one boot where it has to take ACCESS EXCLUSIVE on a table under continuous write,
|
||||
// and all 28 directors reach it at once; a lock timeout here must not restart the instance, which
|
||||
@@ -988,7 +992,7 @@ async function waitForPostgresRetry(random: () => number = Math.random): Promise
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs))
|
||||
}
|
||||
|
||||
class PostgresDatabase implements RelayDatabase {
|
||||
export class PostgresDatabase implements RelayDatabase {
|
||||
readonly dialect = 'postgres' as const
|
||||
private readonly pressure: PostgresPoolPressure
|
||||
private readonly holds = new CellInventoryHoldSamples()
|
||||
@@ -1208,13 +1212,15 @@ async function backfillRelayCellRegions(database: RelayDatabase): Promise<void>
|
||||
)
|
||||
}
|
||||
|
||||
export async function openRelayDatabase(input: {
|
||||
export type RelayDatabaseOpenInput = {
|
||||
databaseUrl?: string
|
||||
dataDir: string
|
||||
poolMax?: number
|
||||
applicationName?: string
|
||||
statementTimeoutMs?: number
|
||||
}): Promise<RelayDatabase> {
|
||||
}
|
||||
|
||||
export async function openRelayDatabase(input: RelayDatabaseOpenInput): Promise<RelayDatabase> {
|
||||
let database: RelayDatabase
|
||||
if (input.databaseUrl) {
|
||||
await applySchemaOnUntimedPool(input.databaseUrl, input.applicationName)
|
||||
|
||||
@@ -18,6 +18,9 @@ import {
|
||||
RELAY_HOST_CAPABILITY_IDLE_REGIONAL_REHOME,
|
||||
RELAY_PROTOCOL_LIMITS,
|
||||
RELAY_CLOSE_CODE,
|
||||
type IdleRegionalRehomeCommit,
|
||||
type IdleRegionalRehomeDeferReason,
|
||||
type IdleRegionalRehomeResult,
|
||||
type RelayHostCloseReason,
|
||||
type RelayRegion
|
||||
} from '@orca-cloud/relay-contract'
|
||||
@@ -191,7 +194,7 @@ export class HostSessionRegistry {
|
||||
{
|
||||
attemptId: string
|
||||
authorityKey: string
|
||||
promise: Promise<{ outcome: 'committed' | 'deferred' | 'stale' }>
|
||||
promise: Promise<IdleRegionalRehomeResult>
|
||||
}
|
||||
>()
|
||||
|
||||
@@ -205,9 +208,9 @@ export class HostSessionRegistry {
|
||||
sourceCellIncarnation: string
|
||||
targetCellId: string
|
||||
},
|
||||
commit: () => Promise<{ outcome: 'committed' | 'deferred' | 'stale' }>,
|
||||
commit: () => Promise<IdleRegionalRehomeCommit>,
|
||||
reconcile: () => Promise<'committed' | 'not-committed' | 'stale'>
|
||||
): Promise<{ outcome: 'busy' | 'committed' | 'deferred' | 'stale' }> {
|
||||
): Promise<IdleRegionalRehomeResult> {
|
||||
const authorityKey = JSON.stringify([
|
||||
input.userId,
|
||||
input.sourceAssignmentEpoch,
|
||||
@@ -236,7 +239,7 @@ export class HostSessionRegistry {
|
||||
!session.socket ||
|
||||
!this.hostCapabilities.get(session.socket)?.has(RELAY_HOST_CAPABILITY_IDLE_REGIONAL_REHOME)
|
||||
)
|
||||
return { outcome: 'deferred' }
|
||||
return { outcome: 'deferred', reason: 'host-unsupported' }
|
||||
if (
|
||||
(this.idleWork.get(input.relayHostId) ?? 0) !== 0 ||
|
||||
session.activeConnIds.size !== 0 ||
|
||||
@@ -246,12 +249,18 @@ export class HostSessionRegistry {
|
||||
return { outcome: 'busy' }
|
||||
const revision = session.authorityRevision
|
||||
const promise = Promise.resolve().then(async () => {
|
||||
let outcome: 'committed' | 'deferred' | 'stale'
|
||||
let outcome: IdleRegionalRehomeCommit['outcome']
|
||||
// The commit's reason survives only while the outcome stays deferred;
|
||||
// a reconcile that finds a durable outcome answers with that instead.
|
||||
let reason: IdleRegionalRehomeDeferReason | undefined
|
||||
try {
|
||||
outcome = (await commit()).outcome
|
||||
const commitResult = await commit()
|
||||
outcome = commitResult.outcome
|
||||
reason = commitResult.reason
|
||||
if (outcome === 'deferred') {
|
||||
const durable = await reconcile()
|
||||
outcome = durable === 'not-committed' ? 'deferred' : durable
|
||||
if (outcome !== 'deferred') reason = undefined
|
||||
}
|
||||
} catch {
|
||||
let delay = 100
|
||||
@@ -259,6 +268,7 @@ export class HostSessionRegistry {
|
||||
try {
|
||||
const durable = await reconcile()
|
||||
outcome = durable === 'not-committed' ? 'deferred' : durable
|
||||
reason = undefined
|
||||
break
|
||||
} catch {
|
||||
await new Promise<void>((resolve) => {
|
||||
@@ -276,7 +286,7 @@ export class HostSessionRegistry {
|
||||
}
|
||||
if (this.idleAttempts.get(input.relayHostId)?.promise === promise)
|
||||
this.idleAttempts.delete(input.relayHostId)
|
||||
return { outcome }
|
||||
return reason === undefined ? { outcome } : { outcome, reason }
|
||||
})
|
||||
this.idleAttempts.set(input.relayHostId, { attemptId: input.attemptId, authorityKey, promise })
|
||||
return promise
|
||||
|
||||
@@ -4,114 +4,296 @@ import type { RelayDatabase, SqlRow } from './database.js'
|
||||
|
||||
export const IDLE_REHOME_PAGE_SIZE = 100
|
||||
|
||||
export async function selectIdleRegionalRehomes(input: {
|
||||
// How many decision rows one poll is allowed to look at. The poll runs about
|
||||
// fifty times a minute across the directors, so its cost has to be set by this
|
||||
// number and not by the size of the fleet or the width of the cohort.
|
||||
export const IDLE_REHOME_DECISION_WINDOW = 500
|
||||
|
||||
// Where the last window ended. A keyset beats OFFSET: `OFFSET n` still has to
|
||||
// produce and throw away n rows, and n grew by a page on every poll that
|
||||
// dispatched, so the scan got more expensive the longer the rollout ran.
|
||||
export type IdleRehomeHostCursor = { userId: string; relayHostId: string } | null
|
||||
|
||||
export type IdleRegionalRehomeCandidate = IdleRegionalRehomeRequest & { sourceCellUrl: string }
|
||||
|
||||
export type IdleRegionalRehomeSelection = {
|
||||
candidates: IdleRegionalRehomeCandidate[]
|
||||
cursor: IdleRehomeHostCursor
|
||||
}
|
||||
|
||||
type SourceCell = {
|
||||
cellId: string
|
||||
region: string
|
||||
cellIncarnation: string
|
||||
startedAt: number
|
||||
cellUrl: string
|
||||
}
|
||||
|
||||
type TargetCell = { cellId: string; capacityRequests: number; reservedRequests: number }
|
||||
|
||||
type SelectionInput = {
|
||||
database: RelayDatabase
|
||||
now: number
|
||||
heartbeatTtlMs: number
|
||||
cohortPercent: number
|
||||
offset: number
|
||||
connectionHeadroom: Map<string, boolean>
|
||||
preferenceMaxAgeMs: number
|
||||
hostCooldownMs: number
|
||||
cursor: IdleRehomeHostCursor
|
||||
connectionHeadroom: ReadonlyMap<string, boolean>
|
||||
cellIsClean: (safety: SqlRow | undefined, runtime: SqlRow, now: number) => boolean
|
||||
}): Promise<Array<IdleRegionalRehomeRequest & { sourceCellUrl: string }>> {
|
||||
const [runtimes, safetyRows] = await Promise.all([
|
||||
input.database.query('SELECT * FROM relay_cell_runtime'),
|
||||
input.database.query('SELECT * FROM relay_cell_rehome_safety')
|
||||
])
|
||||
const cleanCells = runtimes
|
||||
.filter((runtime) =>
|
||||
input.cellIsClean(
|
||||
safetyRows.find((safety) => safety.cell_id === runtime.cell_id),
|
||||
runtime,
|
||||
input.now
|
||||
)
|
||||
)
|
||||
.map((runtime) => String(runtime.cell_id))
|
||||
const targetCells = cleanCells.filter((id) => input.connectionHeadroom.get(id) !== false)
|
||||
if (!cleanCells.length || !targetCells.length) return []
|
||||
}
|
||||
|
||||
const CELL_INVENTORY_QUERY = `SELECT cell.cell_id, cell.cell_url, cell.enabled,
|
||||
cell.capacity_requests, cell.reserved_requests, region.region,
|
||||
admission.admission_state, capability.cell_incarnation AS capability_incarnation,
|
||||
capability.regional_rehome_protocol
|
||||
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_capabilities capability ON capability.cell_id = cell.cell_id`
|
||||
|
||||
export async function selectIdleRegionalRehomes(
|
||||
input: SelectionInput
|
||||
): Promise<IdleRegionalRehomeSelection> {
|
||||
const cells = await readCellInventory(input)
|
||||
if (!cells.sources.size || !cells.targetsByRegion.size) return { candidates: [], cursor: null }
|
||||
const sourceRegions = [...new Set([...cells.sources.values()].map((cell) => cell.region))]
|
||||
const targetRegions = [...cells.targetsByRegion.keys()]
|
||||
const decisionFilter = `outcome = 'conclusive' AND policy_version = 1
|
||||
AND preferred_region IN (${placeholders(targetRegions.length)})
|
||||
AND incumbent_region IN (${placeholders(sourceRegions.length)})
|
||||
AND preferred_region <> incumbent_region
|
||||
AND expires_at > ? AND observed_at >= ? AND cohort_bucket < ?`
|
||||
const decisionParams = [
|
||||
...targetRegions,
|
||||
...sourceRegions,
|
||||
input.now,
|
||||
input.now - input.preferenceMaxAgeMs,
|
||||
input.cohortPercent
|
||||
]
|
||||
const after = input.cursor ? [input.cursor.userId, input.cursor.relayHostId] : []
|
||||
const afterFilter = input.cursor ? 'AND (user_id, relay_host_id) > (?, ?)' : ''
|
||||
|
||||
// The window is taken first and on its own so the poll knows where it stopped
|
||||
// reading, not just where it stopped emitting. Every gate below this point can
|
||||
// reject a host, and a cursor that only advanced past emitted rows would park
|
||||
// on a rejected host forever.
|
||||
const window = await input.database.query(
|
||||
`SELECT user_id, relay_host_id FROM relay_region_decisions
|
||||
WHERE ${decisionFilter} ${afterFilter}
|
||||
ORDER BY user_id, relay_host_id LIMIT ?`,
|
||||
[...decisionParams, ...after, IDLE_REHOME_DECISION_WINDOW]
|
||||
)
|
||||
if (!window.length) return { candidates: [], cursor: null }
|
||||
const windowEnd = window[window.length - 1]!
|
||||
const windowWasFull = window.length === IDLE_REHOME_DECISION_WINDOW
|
||||
|
||||
const sourceList = [...cells.sources.values()]
|
||||
const rows = await input.database.query(
|
||||
`SELECT a.user_id, a.relay_host_id, a.cell_id AS source_cell_id,
|
||||
a.assignment_epoch, host.generation, r.cell_incarnation,
|
||||
s.cell_url, target.cell_id AS target_cell_id
|
||||
FROM relay_region_rehome_control policy
|
||||
JOIN relay_region_decisions d ON d.outcome = 'conclusive'
|
||||
// The verification names the window's keys rather than repeating its LIMIT:
|
||||
// the two reads take separate snapshots, and a decision that turned eligible
|
||||
// between them would otherwise shift the second LIMIT and push the last host
|
||||
// out of it while the cursor still advanced past it.
|
||||
`SELECT d.user_id, d.relay_host_id, d.preferred_region, a.cell_id AS source_cell_id,
|
||||
a.assignment_epoch, host.generation
|
||||
FROM (SELECT user_id, relay_host_id, preferred_region, incumbent_region, assignment_epoch
|
||||
FROM relay_region_decisions
|
||||
WHERE ${decisionFilter}
|
||||
AND (user_id, relay_host_id) IN (${Array.from({ length: window.length }, () => '(?,?)').join(',')})
|
||||
-- The LIMIT cannot truncate a key set this size; it is here because without
|
||||
-- it Postgres flattens the subquery, estimates one row out of the join, and
|
||||
-- drives the whole plan from a sequential scan of the capability table.
|
||||
ORDER BY user_id, relay_host_id LIMIT ?) d
|
||||
JOIN relay_assignments a ON a.user_id = d.user_id AND a.relay_host_id = d.relay_host_id
|
||||
JOIN relay_cells s ON s.cell_id = a.cell_id AND s.enabled = 1
|
||||
JOIN relay_cell_regions sr ON sr.cell_id = a.cell_id
|
||||
JOIN relay_cell_admission sa ON sa.cell_id = a.cell_id AND sa.admission_state = 'general'
|
||||
JOIN relay_cell_runtime r ON r.cell_id = a.cell_id AND r.ready = 1
|
||||
JOIN relay_cell_capabilities c ON c.cell_id = r.cell_id AND c.cell_incarnation = r.cell_incarnation
|
||||
JOIN relay_control_capabilities host ON host.user_id = a.user_id AND host.relay_host_id = a.relay_host_id
|
||||
AND host.cell_id = a.cell_id AND host.assignment_epoch = a.assignment_epoch
|
||||
AND host.cell_incarnation = r.cell_incarnation AND host.idle_regional_rehome = 1
|
||||
JOIN relay_assignment_activity_leases lease ON lease.user_id = host.user_id
|
||||
AND lease.relay_host_id = host.relay_host_id AND lease.activity_id = host.activity_id
|
||||
AND a.assignment_epoch = d.assignment_epoch
|
||||
JOIN (${inlineRows(SOURCE_CELL_COLUMNS, sourceList.length)}) source
|
||||
ON source.cell_id = a.cell_id AND source.region = d.incumbent_region
|
||||
JOIN relay_control_capabilities host ON host.user_id = d.user_id
|
||||
AND host.relay_host_id = d.relay_host_id AND host.cell_id = a.cell_id
|
||||
AND host.assignment_epoch = a.assignment_epoch
|
||||
AND host.cell_incarnation = source.cell_incarnation AND host.idle_regional_rehome = 1
|
||||
JOIN relay_assignment_activity_leases lease ON lease.user_id = d.user_id
|
||||
AND lease.relay_host_id = d.relay_host_id AND lease.activity_id = host.activity_id
|
||||
AND lease.cell_id = a.cell_id AND lease.activity_kind = 'control'
|
||||
JOIN relay_cell_regions tr ON tr.region = d.preferred_region
|
||||
JOIN relay_cells target ON target.cell_id = tr.cell_id AND target.enabled = 1
|
||||
JOIN relay_cell_admission ta ON ta.cell_id = target.cell_id AND ta.admission_state = 'general'
|
||||
JOIN relay_cell_runtime rt ON rt.cell_id = target.cell_id AND rt.ready = 1
|
||||
JOIN relay_cell_capabilities ct ON ct.cell_id = rt.cell_id AND ct.cell_incarnation = rt.cell_incarnation
|
||||
WHERE policy.control_id = 'global' AND policy.enabled = 1 AND policy.not_before <= ?
|
||||
AND d.preferred_region <> sr.region AND d.incumbent_region = sr.region
|
||||
AND d.assignment_epoch = a.assignment_epoch AND d.policy_version = 1
|
||||
AND d.expires_at > ? AND d.observed_at >= ? - policy.preference_max_age_ms
|
||||
AND d.cohort_bucket < ? AND lease.expires_at > ? AND lease.updated_at >= r.started_at
|
||||
AND r.last_heartbeat_at > ? AND rt.last_heartbeat_at > ?
|
||||
AND s.cell_id IN (${cleanCells.map(() => '?').join(',')})
|
||||
AND target.cell_id IN (${targetCells.map(() => '?').join(',')})
|
||||
-- Reserve the moving host's source activity plus its assignment on the target.
|
||||
AND target.reserved_requests + 1 + (
|
||||
SELECT COALESCE(SUM(activity.request_units), 0)
|
||||
FROM relay_assignment_activity_leases activity
|
||||
WHERE activity.user_id = a.user_id AND activity.relay_host_id = a.relay_host_id
|
||||
AND activity.cell_id = a.cell_id
|
||||
) <= target.capacity_requests
|
||||
AND c.regional_rehome_protocol >= 3 AND ct.regional_rehome_protocol >= 3
|
||||
AND NOT EXISTS (SELECT 1 FROM relay_assignment_migrations migration
|
||||
WHERE migration.user_id = a.user_id AND migration.relay_host_id = a.relay_host_id
|
||||
AND lease.expires_at > ? AND lease.updated_at >= source.started_at
|
||||
WHERE NOT EXISTS (SELECT 1 FROM relay_assignment_migrations migration
|
||||
WHERE migration.user_id = d.user_id AND migration.relay_host_id = d.relay_host_id
|
||||
AND migration.completed_at IS NULL AND migration.aborted_at IS NULL)
|
||||
AND NOT EXISTS (SELECT 1 FROM relay_region_rehome_attempts attempt
|
||||
WHERE attempt.user_id = a.user_id AND attempt.relay_host_id = a.relay_host_id
|
||||
AND attempt.created_at > ? - policy.host_cooldown_ms)
|
||||
ORDER BY a.user_id, a.relay_host_id, host.generation DESC,
|
||||
(target.reserved_requests + rt.observed_requests) * 1.0 / target.capacity_requests,
|
||||
target.cell_id
|
||||
LIMIT ? OFFSET ?`,
|
||||
WHERE attempt.user_id = d.user_id AND attempt.relay_host_id = d.relay_host_id
|
||||
AND attempt.created_at > ?)
|
||||
ORDER BY d.user_id, d.relay_host_id, host.generation DESC
|
||||
-- Counted in hosts, because a host with one eligible target has to be able
|
||||
-- to fill a page on its own. A host with many leaves part of this page
|
||||
-- unread, and the cursor stops where the page stopped, so it is re-read
|
||||
-- next poll rather than skipped.
|
||||
LIMIT ?`,
|
||||
[
|
||||
...decisionParams,
|
||||
...window.flatMap((row) => [row.user_id, row.relay_host_id]),
|
||||
IDLE_REHOME_DECISION_WINDOW,
|
||||
...sourceList.flatMap((cell) => [cell.cellId, cell.region, cell.cellIncarnation, cell.startedAt]),
|
||||
input.now,
|
||||
input.now,
|
||||
input.now,
|
||||
input.cohortPercent,
|
||||
input.now,
|
||||
input.now - input.heartbeatTtlMs,
|
||||
input.now - input.heartbeatTtlMs,
|
||||
...cleanCells,
|
||||
...targetCells,
|
||||
input.now,
|
||||
IDLE_REHOME_PAGE_SIZE,
|
||||
input.offset
|
||||
input.now - input.hostCooldownMs,
|
||||
IDLE_REHOME_PAGE_SIZE
|
||||
]
|
||||
)
|
||||
return rows.map((row) => {
|
||||
const request = {
|
||||
v: 1 as const,
|
||||
userId: String(row.user_id),
|
||||
relayHostId: String(row.relay_host_id),
|
||||
sourceCellId: String(row.source_cell_id),
|
||||
sourceCellIncarnation: String(row.cell_incarnation),
|
||||
sourceAssignmentEpoch: Number(row.assignment_epoch),
|
||||
sourceGeneration: Number(row.generation),
|
||||
targetCellId: String(row.target_cell_id)
|
||||
const units = rows.length ? await sourceRequestUnits(input.database, rows) : new Map<string, number>()
|
||||
|
||||
const candidates: IdleRegionalRehomeCandidate[] = []
|
||||
let stoppedAt: IdleRehomeHostCursor = null
|
||||
for (const row of rows) {
|
||||
// Whole hosts only: the lower-priority targets are a host's fallbacks when
|
||||
// the first one defers, and splitting them across pages loses them.
|
||||
if (candidates.length >= IDLE_REHOME_PAGE_SIZE) {
|
||||
return { candidates, cursor: stoppedAt }
|
||||
}
|
||||
// UUIDv5 keeps retries on every director bound to the same source authority and target.
|
||||
const digest = createHash('sha1')
|
||||
.update(Buffer.from('0a1c5a9b197b4ea8b6f1f3bcaa3d712c', 'hex'))
|
||||
.update(JSON.stringify(request))
|
||||
.digest()
|
||||
digest[6] = (digest[6]! & 0x0f) | 0x50
|
||||
digest[8] = (digest[8]! & 0x3f) | 0x80
|
||||
const hex = digest.subarray(0, 16).toString('hex')
|
||||
const attemptId = `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
|
||||
return { ...request, attemptId, sourceCellUrl: String(row.cell_url) }
|
||||
})
|
||||
const source = cells.sources.get(String(row.source_cell_id))!
|
||||
const sourceUnits = units.get(hostKey(row)) ?? 0
|
||||
for (const target of cells.targetsByRegion.get(String(row.preferred_region)) ?? []) {
|
||||
if (target.reservedRequests + 1 + sourceUnits > target.capacityRequests) continue
|
||||
candidates.push(idleRehomeCandidate(row, source, target.cellId))
|
||||
}
|
||||
stoppedAt = { userId: String(row.user_id), relayHostId: String(row.relay_host_id) }
|
||||
}
|
||||
// A full verification page may have been cut short of the window's end, so only
|
||||
// a page that ran the window out may wrap to the head of the keyspace.
|
||||
if (rows.length === IDLE_REHOME_PAGE_SIZE) return { candidates, cursor: stoppedAt }
|
||||
return {
|
||||
candidates,
|
||||
cursor: windowWasFull
|
||||
? { userId: String(windowEnd.user_id), relayHostId: String(windowEnd.relay_host_id) }
|
||||
: null
|
||||
}
|
||||
}
|
||||
|
||||
// Every cell predicate the candidate join used to re-evaluate per (host, cell)
|
||||
// pair. There are tens of cells and tens of thousands of hosts, so this is
|
||||
// resolved once per poll against the four small inventory tables.
|
||||
async function readCellInventory(
|
||||
input: SelectionInput
|
||||
): Promise<{ sources: Map<string, SourceCell>; targetsByRegion: Map<string, TargetCell[]> }> {
|
||||
const { database, now } = input
|
||||
const [runtimeRows, safetyRows, inventory] = await Promise.all([
|
||||
database.query('SELECT * FROM relay_cell_runtime'),
|
||||
database.query('SELECT * FROM relay_cell_rehome_safety'),
|
||||
database.query(CELL_INVENTORY_QUERY)
|
||||
])
|
||||
const runtimes = new Map(runtimeRows.map((row) => [String(row.cell_id), row]))
|
||||
const safety = new Map(safetyRows.map((row) => [String(row.cell_id), row]))
|
||||
const sources = new Map<string, SourceCell>()
|
||||
const targetsByRegion = new Map<string, TargetCell[]>()
|
||||
const load = new Map<string, number>()
|
||||
for (const cell of inventory) {
|
||||
const cellId = String(cell.cell_id)
|
||||
const runtime = runtimes.get(cellId)
|
||||
if (!runtime || !input.cellIsClean(safety.get(cellId), runtime, now)) continue
|
||||
if (
|
||||
Number(cell.enabled) !== 1 ||
|
||||
cell.admission_state !== 'general' ||
|
||||
cell.region == null ||
|
||||
Number(runtime.ready) !== 1 ||
|
||||
Number(runtime.last_heartbeat_at) <= now - input.heartbeatTtlMs ||
|
||||
cell.capability_incarnation == null ||
|
||||
String(cell.capability_incarnation) !== String(runtime.cell_incarnation) ||
|
||||
Number(cell.regional_rehome_protocol) < 3
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const region = String(cell.region)
|
||||
sources.set(cellId, {
|
||||
cellId,
|
||||
region,
|
||||
cellIncarnation: String(runtime.cell_incarnation),
|
||||
startedAt: Number(runtime.started_at),
|
||||
cellUrl: String(cell.cell_url)
|
||||
})
|
||||
if (input.connectionHeadroom.get(cellId) === false) continue
|
||||
const capacityRequests = Number(cell.capacity_requests)
|
||||
const reservedRequests = Number(cell.reserved_requests)
|
||||
const targets = targetsByRegion.get(region) ?? []
|
||||
targets.push({ cellId, capacityRequests, reservedRequests })
|
||||
targetsByRegion.set(region, targets)
|
||||
load.set(cellId, (reservedRequests + Number(runtime.observed_requests)) / capacityRequests)
|
||||
}
|
||||
for (const targets of targetsByRegion.values()) {
|
||||
targets.sort(
|
||||
(left, right) =>
|
||||
load.get(left.cellId)! - load.get(right.cellId)! || (left.cellId < right.cellId ? -1 : 1)
|
||||
)
|
||||
}
|
||||
return { sources, targetsByRegion }
|
||||
}
|
||||
|
||||
// One grouped read for the page instead of a correlated aggregate per (host, cell) pair.
|
||||
async function sourceRequestUnits(
|
||||
database: RelayDatabase,
|
||||
rows: SqlRow[]
|
||||
): Promise<Map<string, number>> {
|
||||
const seen = new Set<string>()
|
||||
const params: unknown[] = []
|
||||
for (const row of rows) {
|
||||
if (seen.has(hostKey(row))) continue
|
||||
seen.add(hostKey(row))
|
||||
params.push(row.user_id, row.relay_host_id, row.source_cell_id)
|
||||
}
|
||||
const sums = await database.query(
|
||||
`SELECT user_id, relay_host_id, COALESCE(SUM(request_units), 0) AS request_units
|
||||
FROM relay_assignment_activity_leases
|
||||
WHERE (user_id, relay_host_id, cell_id) IN (${Array.from({ length: seen.size }, () => '(?,?,?)').join(',')})
|
||||
GROUP BY user_id, relay_host_id`,
|
||||
params
|
||||
)
|
||||
return new Map(sums.map((row) => [hostKey(row), Number(row.request_units)]))
|
||||
}
|
||||
|
||||
const SOURCE_CELL_COLUMNS = [
|
||||
['cell_id', 'TEXT'],
|
||||
['region', 'TEXT'],
|
||||
['cell_incarnation', 'TEXT'],
|
||||
['started_at', 'BIGINT']
|
||||
] as const
|
||||
|
||||
function placeholders(count: number): string {
|
||||
return Array.from({ length: count }, () => '?').join(',')
|
||||
}
|
||||
|
||||
// A derived table the planner can hash, in the one syntax both Postgres and the
|
||||
// SQLite test engine accept (`VALUES ... AS t(col)` and LATERAL are not common to
|
||||
// both). Only the first branch is cast; both engines take the union's types from it.
|
||||
function inlineRows(columns: ReadonlyArray<readonly [string, string]>, rows: number): string {
|
||||
const first = columns.map(([name, type]) => `CAST(? AS ${type}) AS ${name}`)
|
||||
const rest = Array.from({ length: rows - 1 }, () => `UNION ALL SELECT ${placeholders(columns.length)}`)
|
||||
return `SELECT ${first.join(', ')} ${rest.join(' ')}`
|
||||
}
|
||||
|
||||
function hostKey(row: SqlRow): string {
|
||||
return `${String(row.user_id)} | ||||