Files
spinifex/.github/workflows/perf-3node.yml
T
Josh Siviour b0faa72428 ci: benchmark Predastore across a real 3-node Spinifex env (#1015)
* ci: benchmark Predastore across a real 3-node Spinifex env

The nightly throughput job runs three s3d processes on loopback aliases on a
single box against local NVMe, so nothing measures Predastore in the shape it
ships in: three machines, with reads crossing a real NIC between hosts.

This provisions a 3-node env the way the multi-node e2e job does, then hands
its three gates to the existing harness through PERF_EXTERNAL_HOSTS. The
harness skips its own start and stop and measures them directly, so the
workloads and analysis are unchanged and the two numbers stay comparable.

It runs on a systemd LAN runner rather than the ephemeral pool because a pool
VM sits behind the geneve overlay at MTU 1408, which would put the runner's
own network path inside the measurement.

* ci: trigger the 3-node benchmark on its own branch for a first run

A workflow_dispatch only resolves against the default branch, so this cannot
be dispatched until it is merged. The push trigger is scoped to this branch
and comes back out once the workflow is on dev.

* fix: measure as the identity the provisioned cluster actually mints

spx admin init generates a random access key pair at bootstrap and writes it
to node1's ~/.aws/credentials, so the benchmark's built-in key is one this
cluster has never heard of and every run died on the first CreateBucket.

The pair is read and used inside a single step, the way e2e-ddil.yml does it:
a step-level env: block is echoed verbatim into the run log, a shell variable
is not.

* ci: default the 3-node benchmark to the compare preset

smoke was the right default while the pipeline was unproven. It is green now,
and compare is what the nightly loopback run uses, so it is the preset whose
numbers can be set beside it.

* ci: make the cluster's ref a knob, pinned to v1.18.0 for one run

The harness stays on this branch while the cluster is built from whatever ref
is asked for, so an old release is measured with the same ruler as the current
one. run-info.txt records the deployed ref rather than the harness checkout.

The v1.18.0 default is temporary: a push event carries no dispatch inputs, and
this cannot be dispatched until it is on dev. It comes back out after the run.

* test: probe ranged GETs, which no workload here exercises

Warp reads whole objects, so the throughput figures stay healthy while ranged
GETs read the whole object per request. The probe reads the same boundary
crossing range from a 64 MiB and a 256 MiB object: under read amplification
the cost tracks object size, and otherwise the two match.

Cluster ref is pinned to the fix branch for this run and comes back out after.

* test: run the ranged GET probe against dev to prove it fails there

A probe that has only ever been green proves nothing about the fix. Pointing
the cluster at dev, which still has the defect, is the control that shows the
probe detects it. The pin comes back out after.

* ci: drop the branch push trigger and the cluster ref pin

Both were scaffolding to run this before it existed on dev. The workflow is
dispatch only again, and cluster_ref defaults to the branch under test.
2026-09-10 15:39:00 +10:00

416 lines
18 KiB
YAML

name: Predastore 3-Node Performance
on:
workflow_dispatch:
inputs:
perf_preset:
description: "Warp workload preset: smoke (30s) or compare (2m)"
required: false
default: compare
type: choice
options:
- smoke
- compare
cluster_ref:
description: "Git ref to build the cluster from, e.g. v1.18.0. Empty uses this branch."
required: false
default: ""
type: string
# Shared with e2e.yml and e2e-nightly.yml: this job provisions on the same
# hypervisors they do, so a run in flight there and here would compete for
# disks and libvirt guests. Queued, not cancelled, same as those workflows.
concurrency:
group: e2e-hypervisors
cancel-in-progress: false
permissions:
contents: read
env:
GIT_BRANCH: ${{ github.ref_name }}
SPINIFEX_OTLP_ENDPOINT: ${{ vars.SPINIFEX_OTLP_ENDPOINT || 'http://192.168.1.13:8200' }}
jobs:
perf_3node:
name: Predastore Throughput (3-node)
# ci-multi is a systemd runner on the LAN, not the ephemeral pool. A pool
# VM sits behind the geneve overlay at MTU 1408, which would put the
# runner's own network path inside the thing being measured.
runs-on: [self-hosted, ci-multi]
timeout-minutes: 90
env:
TOFU_LOG: /tmp/e2e-tofu-perf3node-${{ github.run_id }}.log
SPINIFEX_OTEL_ATTRS: ci.run_id=${{ github.run_id }},ci.workflow=${{ github.workflow }},ci.cell=perf-3node
steps:
- name: Reap Orphan Procs
run: |
set +e
UID_SELF=$(id -u)
WS="${GITHUB_WORKSPACE}"
reap() {
local sig="$1"
for pid in $(pgrep -u "$UID_SELF" -x tofu) $(pgrep -u "$UID_SELF" -x terraform); do
cwd=$(readlink "/proc/$pid/cwd" 2>/dev/null) || continue
case "$cwd" in
"$WS"|"$WS"/*) echo "reap $sig pid=$pid cwd=$cwd"; kill "-$sig" "$pid" 2>/dev/null ;;
esac
done
}
reap TERM
sleep 3
reap KILL
find "$WS" -name '.terraform.tfstate.lock.info' -delete 2>/dev/null
true
- name: Clean Workspace
run: |
awk -v w="$GITHUB_WORKSPACE" '$2 ~ "^"w {print $2}' /proc/mounts \
| sort -r | xargs -r -I{} sudo umount -l -R {} || true
sudo rm -rf --one-file-system "$GITHUB_WORKSPACE"/*
# ci-multi and ci-single resolve to the same physical host, so the
# nvme path cell-23 uses for its throughput run is already there.
# Pointing TMPDIR at it keeps warp's payloads off a small /tmp tmpfs.
- name: Configure work directories
run: |
E2E_ROOT=/mnt/nvme/e2e
mkdir -p "$E2E_ROOT/work" 2>/dev/null ||
{ sudo mkdir -p "$E2E_ROOT/work" && sudo chown "$(id -u):$(id -g)" "$E2E_ROOT" "$E2E_ROOT/work"; }
echo "TMPDIR=$E2E_ROOT/work" >> "$GITHUB_ENV"
echo "PERF_RESULTS_ROOT=$E2E_ROOT/predastore-perf-3node-$GITHUB_RUN_ID" >> "$GITHUB_ENV"
- uses: actions/create-github-app-token@v3
id: app-token
with:
client-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
repositories: mulga
- uses: actions/checkout@v7
with:
repository: mulgadc/mulga
token: ${{ steps.app-token.outputs.token }}
sparse-checkout: scripts/tofu-cluster
path: mulga
- uses: actions/checkout@v7
with:
path: spinifex
- uses: actions/setup-go@v7
with:
go-version-file: spinifex/go.mod
cache: false
- name: Re-enable Go toolchain auto-download
run: echo "GOTOOLCHAIN=auto" >> "$GITHUB_ENV"
# Same-named branch when one exists, dev otherwise. Matches how the
# e2e path picks siblings, so this measures the same predastore
# commit a cross-repo change would be built and tested against.
- name: Resolve sibling refs
id: siblings
env:
BRANCH: ${{ env.GIT_BRANCH }}
run: bash .github/resolve-sibling-refs.sh
working-directory: spinifex
- uses: actions/checkout@v7
id: predastore
with:
repository: mulgadc/predastore
ref: ${{ steps.siblings.outputs.predastore }}
path: predastore
# The cluster's ref is a knob and the harness's is not. Measuring an old
# release with a new harness is the only way its number can be set beside
# a current one; rebuilding both would change the ruler and the thing.
- name: Build Install Artifacts
working-directory: mulga/scripts/tofu-cluster
env:
CLUSTER_REF: ${{ inputs.cluster_ref }}
run: |
./build-install-artifacts.sh "${CLUSTER_REF:-$GIT_BRANCH}" \
"${RUNNER_TEMP}/spinifex-distro.tar.gz" \
"${RUNNER_TEMP}/setup.sh" \
"${RUNNER_TEMP}/spinifex-tests.tar" \
--skip-e2e-build
# The libvirt provider dials the hypervisors by bare hostname. A pool VM
# sits inside a VPC and cannot resolve them; a LAN runner already can, so
# this is self-gating on the lookup rather than on the runner's labels.
- name: Map hypervisor names
working-directory: mulga/scripts/tofu-cluster
run: |
set -euo pipefail
if getent hosts banksia >/dev/null; then
echo "hypervisor names already resolve"
else
sudo tee -a /etc/hosts < network/hosts >/dev/null
echo "added $(grep -c '^[0-9]' network/hosts) entries to /etc/hosts"
fi
# Pinned rather than StrictHostKeyChecking=no: these hops reach the
# hypervisors themselves, so an unverified key is the one place in
# this job where accepting anything would matter.
if ssh-keygen -F banksia >/dev/null 2>&1; then
echo "hypervisor host keys already known"
else
install -d -m 700 ~/.ssh
cat network/known_hosts >> ~/.ssh/known_hosts
chmod 600 ~/.ssh/known_hosts
echo "pinned $(grep -c '^[a-z]' network/known_hosts) host keys"
fi
- name: Provision VMs
working-directory: mulga/scripts/tofu-cluster
run: |
set -euo pipefail
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
export TF_VAR_git_branch="${GIT_BRANCH}"
quiet_run "tofu init" "$TOFU_LOG" tofu init -input=false
quiet_run "tofu workspace select" "$TOFU_LOG" tofu workspace select -or-create "${SPINIFEX_CI_ENV}"
quiet_run "tofu destroy (pre-clean)" "$TOFU_LOG" tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false || true
quiet_run "scrub-env (pre-clean)" "$TOFU_LOG" ./scrub-env.sh "${SPINIFEX_CI_ENV}" || true
retry_run "tofu apply" "$TOFU_LOG" tofu apply -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false
ok "3-node cluster provisioned"
- name: Bootstrap via Binary Install
working-directory: mulga/scripts/tofu-cluster
run: |
SPX_EXTERNAL_POOL="${SPINIFEX_CI_POOL}" \
./bootstrap-install.sh "${SPINIFEX_CI_ENV}" \
"${RUNNER_TEMP}/setup.sh" \
"${RUNNER_TEMP}/spinifex-distro.tar.gz" \
--test-tar "${RUNNER_TEMP}/spinifex-tests.tar"
- name: Resolve cluster SSH details
id: cluster
working-directory: mulga/scripts/tofu-cluster
run: |
INVENTORY=$(tofu output -json inventory)
NODE1_IP=$(echo "$INVENTORY" | jq -r '.nodes[0].wan_ip')
SSH_KEY=$(echo "$INVENTORY" | jq -r '.ssh_key_path')
SSH_KEY="${SSH_KEY/#\~/$HOME}"
SSH_USER=$(echo "$INVENTORY" | jq -r '.ssh_user')
REGION=$(echo "$INVENTORY" | jq -r '.region')
NODE_COUNT=$(echo "$INVENTORY" | jq '.nodes | length')
NODES_CSV=""
for i in $(seq 0 $((NODE_COUNT - 1))); do
ip=$(echo "$INVENTORY" | jq -r ".nodes[$i].wan_ip")
if [ -z "$NODES_CSV" ]; then NODES_CSV="$ip"; else NODES_CSV="$NODES_CSV,$ip"; fi
done
echo "node1_ip=${NODE1_IP}" >> "$GITHUB_OUTPUT"
echo "ssh_key=${SSH_KEY}" >> "$GITHUB_OUTPUT"
echo "ssh_user=${SSH_USER}" >> "$GITHUB_OUTPUT"
echo "region=${REGION}" >> "$GITHUB_OUTPUT"
echo "nodes_csv=${NODES_CSV}" >> "$GITHUB_OUTPUT"
# PERF_EXTERNAL_HOSTS wants host:port, and the gate listens on 8443 on
# every node. run-info.txt from this run is then comparable with the
# nightly's loopback one, since the harness itself never changes.
- name: Resolve PERF_EXTERNAL_HOSTS
id: perf_hosts
run: |
set -euo pipefail
IFS=',' read -ra NODE_LIST <<< "${{ steps.cluster.outputs.nodes_csv }}"
HOSTS=""
for ip in "${NODE_LIST[@]}"; do
[ -z "$ip" ] && continue
if [ -z "$HOSTS" ]; then HOSTS="$ip:8443"; else HOSTS="$HOSTS,$ip:8443"; fi
done
echo "hosts=${HOSTS}" >> "$GITHUB_OUTPUT"
# A slow bootstrap should fail here, legibly, rather than inside warp
# with a wall of connection-refused errors. Bounded at 5 minutes per
# gate so a genuinely dead node still fails the job in finite time.
- name: Wait for gates to answer on :8443
run: |
set -euo pipefail
IFS=',' read -ra GATES <<< "${{ steps.perf_hosts.outputs.hosts }}"
for gate in "${GATES[@]}"; do
echo "waiting for https://$gate"
up=0
for i in $(seq 1 60); do
if curl -k -sS --max-time 5 -o /dev/null "https://$gate/"; then
up=1
break
fi
sleep 5
done
[ "$up" -eq 1 ] || { echo "::error::gate $gate never answered HTTPS on :8443"; exit 1; }
echo "gate $gate is up"
done
# Warp reads whole objects, so no workload here touches a ranged read and
# the throughput figures stay healthy while ranged GETs are broken. This
# probe is the only thing in CI that looks at them.
- name: Ranged GET probe
env:
GATE: ${{ steps.cluster.outputs.node1_ip }}
SSH_KEY: ${{ steps.cluster.outputs.ssh_key }}
SSH_USER: ${{ steps.cluster.outputs.ssh_user }}
NODE1_IP: ${{ steps.cluster.outputs.node1_ip }}
CLUSTER_REGION: ${{ steps.cluster.outputs.region }}
run: |
set -euo pipefail
CREDS=$(ssh -i "${SSH_KEY}" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
"${SSH_USER}@${NODE1_IP}" 'cat ~/.aws/credentials')
AK=$(echo "$CREDS" | awk -F '= *' '/^aws_access_key_id/ {print $2; exit}')
SK=$(echo "$CREDS" | awk -F '= *' '/^aws_secret_access_key/ {print $2; exit}')
echo "::add-mask::$SK"
B="ranged-probe-${GITHUB_RUN_ID}"
AWSX=(--region "$CLUSTER_REGION" --endpoint-url "https://${GATE}:8443" --no-verify-ssl)
export AWS_ACCESS_KEY_ID="$AK" AWS_SECRET_ACCESS_KEY="$SK"
aws s3 mb "s3://$B" "${AWSX[@]}"
trap 'aws s3 rb "s3://$B" --force "${AWSX[@]}" >/dev/null 2>&1 || true' EXIT
# Two sizes: read amplification makes a boundary-crossing range cost
# the whole object, so the fault shows as time tracking object size.
for mb in 64 256; do
head -c $((mb*1024*1024)) /dev/urandom > "${TMPDIR}/probe-${mb}.bin"
aws s3 cp "${TMPDIR}/probe-${mb}.bin" "s3://$B/probe-${mb}.bin" --only-show-errors "${AWSX[@]}"
rm -f "${TMPDIR}/probe-${mb}.bin"
done
t() {
curl -sk --aws-sigv4 "aws:amz:${CLUSTER_REGION}:s3" -u "$AK:$SK" \
-H "Range: bytes=0-$1" -o /dev/null -w '%{time_total}' \
--url "https://${GATE}:8443/${B}/probe-${2}.bin"
}
# 4194304 is the last byte inside the first 4 MiB block; one more
# crosses into the second and leaves the single-shard fast path.
IN64=$(t 4194303 64); OUT64=$(t 4194304 64)
IN256=$(t 4194303 256); OUT256=$(t 4194304 256)
printf '%-10s in-block=%ss crossing=%ss\n' "64MiB" "$IN64" "$OUT64"
printf '%-10s in-block=%ss crossing=%ss\n' "256MiB" "$IN256" "$OUT256"
# The 4x object-size ratio is the signal, and it is compared against
# the crossing reads alone so a slow runner cancels out of both sides.
awk -v a="$OUT64" -v b="$OUT256" 'BEGIN{
r = (a > 0) ? b/a : 0
printf "crossing-range cost ratio 256MiB/64MiB = %.2f (object size ratio is 4.00)\n", r
if (r > 2.0) {
print "::error::a boundary-crossing ranged GET scales with object size, so it is reading the whole object"
exit 1
}
}'
echo "ranged GET does not scale with object size"
# The benchmark works in $TMPDIR and predastore refuses writes below a 5%
# free-space watermark, so this asserts the same headroom cell-23 does.
# tmpfs is rejected outright: that would measure RAM, not the gates.
- name: Preflight
run: |
set -euo pipefail
mkdir -p "$TMPDIR"
fstype=$(df --output=fstype "$TMPDIR" | tail -1 | tr -d ' ')
case "$fstype" in
tmpfs|ramfs)
echo "::error::$TMPDIR is $fstype — a storage benchmark there measures RAM"
exit 1 ;;
esac
find "$TMPDIR" -maxdepth 1 -name 'predastore-e2e-performance.*' \
-type d -mtime +0 -exec rm -rf {} + 2>/dev/null || true
df -h "$TMPDIR"
avail_gb=$(df -BG --output=avail "$TMPDIR" | tail -1 | tr -dc '0-9')
pct_used=$(df --output=pcent "$TMPDIR" | tail -1 | tr -dc '0-9')
if [ "$pct_used" -ge 90 ] || [ "$avail_gb" -lt 20 ]; then
echo "::error::$TMPDIR ($fstype) is ${pct_used}% full with ${avail_gb} GiB free."
echo "::error::The benchmark needs headroom above predastore's 5% watermark."
exit 1
fi
- name: Install warp
working-directory: predastore
run: make warp-install
# PERF_EXTERNAL_HOSTS skips profile selection, port offsetting, and
# cluster start/stop, and measures these three gates directly. Same
# workloads and analysis as the nightly loopback run, so the numbers
# from the two are directly comparable.
#
# The cluster's credentials are read and used inside this one step, the
# way e2e-ddil.yml does it: a step-level env: block is echoed verbatim
# into the run log, and a shell variable is not.
- name: Run 3-node throughput benchmark
working-directory: predastore
env:
PERF_EXTERNAL_HOSTS: ${{ steps.perf_hosts.outputs.hosts }}
PERF_CONFIGS: spinifex-3node
PERF_PRESET: ${{ inputs.perf_preset || 'compare' }}
# What is deployed, not what the harness was checked out at. With a
# cluster_ref those differ, and run-info.txt records the cluster.
PERF_EXTERNAL_SHA: ${{ inputs.cluster_ref || steps.predastore.outputs.commit }}
SSH_KEY: ${{ steps.cluster.outputs.ssh_key }}
SSH_USER: ${{ steps.cluster.outputs.ssh_user }}
NODE1_IP: ${{ steps.cluster.outputs.node1_ip }}
CLUSTER_REGION: ${{ steps.cluster.outputs.region }}
run: |
set -euo pipefail
# spx admin init mints a random pair at bootstrap and writes it to
# node1's ~/.aws/credentials. The benchmark's built-in key is the one
# the loopback configs trust, which this cluster has never heard of.
CREDS=$(ssh -i "${SSH_KEY}" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
"${SSH_USER}@${NODE1_IP}" 'cat ~/.aws/credentials')
PERF_ACCESS_KEY=$(echo "$CREDS" | awk -F '= *' '/^aws_access_key_id/ {print $2; exit}')
PERF_SECRET_KEY=$(echo "$CREDS" | awk -F '= *' '/^aws_secret_access_key/ {print $2; exit}')
if [ -z "$PERF_ACCESS_KEY" ] || [ -z "$PERF_SECRET_KEY" ]; then
echo "::error::could not read spinifex AWS credentials from ${NODE1_IP}"
exit 1
fi
echo "::add-mask::$PERF_SECRET_KEY"
mkdir -p "$PERF_RESULTS_ROOT"
PERF_ACCESS_KEY="$PERF_ACCESS_KEY" \
PERF_SECRET_KEY="$PERF_SECRET_KEY" \
PERF_REGION="$CLUSTER_REGION" \
make e2e-performance 2>&1 | tee "$PERF_RESULTS_ROOT/e2e-performance.log"
# Report-only, matching cell-23: the baseline comparison and any
# regression gate are future work once run-to-run spread is known from
# real bare-metal runs.
- uses: actions/upload-artifact@v7
if: always()
with:
name: predastore-perf-3node-${{ github.run_id }}
path: ${{ env.PERF_RESULTS_ROOT }}
if-no-files-found: warn
retention-days: 90
- name: Clean up results
if: always()
run: rm -rf "${PERF_RESULTS_ROOT}"
- name: Teardown
if: always()
working-directory: mulga/scripts/tofu-cluster
run: |
set +e
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
UID_SELF=$(id -u)
WS="${GITHUB_WORKSPACE}"
reap() {
local sig="$1"
for pid in $(pgrep -u "$UID_SELF" -x tofu) $(pgrep -u "$UID_SELF" -x terraform); do
cwd=$(readlink "/proc/$pid/cwd" 2>/dev/null) || continue
case "$cwd" in
"$WS"|"$WS"/*) kill "-$sig" "$pid" 2>/dev/null ;;
esac
done
}
reap TERM
sleep 2
reap KILL
find "$WS" -name '.terraform.tfstate.lock.info' -delete 2>/dev/null
quiet_run "tofu workspace select" "$TOFU_LOG" tofu workspace select -or-create "${SPINIFEX_CI_ENV}"
quiet_run "telemetry flush" "$TOFU_LOG" timeout 120 ./flush-telemetry.sh "${SPINIFEX_CI_ENV}" || true
retry_run "tofu destroy" "$TOFU_LOG" timeout 600 tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false
quiet_run "scrub-env" "$TOFU_LOG" ./scrub-env.sh "${SPINIFEX_CI_ENV}"
ok "teardown complete"
- name: Upload tofu log
if: always()
uses: actions/upload-artifact@v7
with:
name: tofu-log-perf3node-${{ github.run_id }}
path: ${{ env.TOFU_LOG }}
if-no-files-found: ignore
retention-days: 14