mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
Merge branch 'stack-reconcile' into stack-final
This commit is contained in:
@@ -23,6 +23,9 @@
|
||||
# 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
|
||||
@@ -48,3 +51,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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+83
-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,64 @@ 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"
|
||||
|
||||
- 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.
|
||||
- name: Builder, override census and render check
|
||||
env:
|
||||
ORCA_MOBILE_WEB_APP_DEPS_REQUIRED: '1'
|
||||
run: |
|
||||
pnpm exec vitest run --config config/vitest.config.ts \
|
||||
config/scripts/build-mobile-web-app-bundle.test.mjs \
|
||||
config/scripts/mobile-web-app-web-overrides.test.mjs \
|
||||
config/scripts/mobile-web-app-render.test.mjs
|
||||
|
||||
cross-version-wire:
|
||||
name: cross-version wire compatibility
|
||||
needs: [code_paths]
|
||||
@@ -695,6 +741,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 +794,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 +914,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 +1079,7 @@ jobs:
|
||||
- shell_contracts
|
||||
- test
|
||||
- orcad_browser
|
||||
- mobile_web_app
|
||||
- cross-version-wire
|
||||
- managed_hook_node18
|
||||
- package
|
||||
@@ -1053,6 +1116,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 +1160,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"
|
||||
|
||||
@@ -871,8 +871,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 +1097,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
|
||||
@@ -1204,22 +1219,33 @@ 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.
|
||||
# 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 composite actions from the workflow ref
|
||||
if: matrix.platform == 'win' && github.run_attempt == 1
|
||||
shell: bash
|
||||
env:
|
||||
WORKFLOW_SHA: ${{ github.workflow_sha }}
|
||||
PLATFORM: ${{ matrix.platform }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
action_path=".github/actions/install-signpath-module/action.yml"
|
||||
if [ -f "$action_path" ]; then
|
||||
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 +1258,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 +1301,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.
|
||||
|
||||
@@ -47,6 +47,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 +77,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:
|
||||
|
||||
@@ -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
|
||||
|
||||
+39
-18
@@ -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'
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
@@ -391,6 +399,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
|
||||
@@ -921,7 +948,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 +2703,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,28 +3365,57 @@ 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(
|
||||
@@ -7124,6 +7189,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
|
||||
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1208,13 +1208,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)
|
||||
|
||||
@@ -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)} | ||||