chore(load): start implementation of load tests
initial test from Ed's nuc:
metric pctl baseline current delta status
-----------------------------------------------------------------------------------------------
folder_cascade.list_depth1 p50 0.3ms 0.3ms -6.3% ok
folder_cascade.list_depth1 p95 2.3ms 0.5ms -75.7% ok
folder_cascade.list_depth1 p99 4.7ms 2.5ms -48.1% ok
folder_cascade.list_depth4 p50 0.4ms 0.3ms -10.0% ok
folder_cascade.list_depth4 p95 0.9ms 0.6ms -31.2% ok
folder_cascade.list_depth4 p99 2.4ms 1.0ms -56.7% ok
folder_cascade.list_depth8 p50 0.3ms 0.3ms -8.8% ok
folder_cascade.list_depth8 p95 0.6ms 0.5ms -22.2% ok
folder_cascade.list_depth8 p99 1.9ms 0.5ms -71.5% ok
folder_cascade.list_depth_deep p50 0.3ms 0.3ms -5.0% ok
folder_cascade.list_depth_deep p95 0.6ms 0.5ms -18.6% ok
folder_cascade.list_depth_deep p99 2.0ms 0.7ms -67.2% ok
share_cascade_rebac.list_grants p50 0.4ms 0.3ms -27.6% ok
share_cascade_rebac.list_grants p95 1.2ms 0.5ms -57.0% ok
share_cascade_rebac.list_grants p99 1.7ms 1.1ms -36.7% ok
share_cascade_rebac.fetch_as_grantee_depth1 p50 0.5ms 0.5ms -11.1% ok
share_cascade_rebac.fetch_as_grantee_depth1 p95 1.1ms 0.7ms -41.2% ok
share_cascade_rebac.fetch_as_grantee_depth1 p99 3.0ms 1.3ms -58.3% ok
share_cascade_rebac.fetch_as_grantee_depth4 p50 0.5ms 0.5ms -13.7% ok
share_cascade_rebac.fetch_as_grantee_depth4 p95 1.4ms 0.7ms -50.5% ok
share_cascade_rebac.fetch_as_grantee_depth4 p99 2.2ms 1.1ms -49.3% ok
share_cascade_rebac.fetch_as_grantee_depth8 p50 0.5ms 0.4ms -16.0% ok
share_cascade_rebac.fetch_as_grantee_depth8 p95 0.9ms 0.7ms -26.1% ok
share_cascade_rebac.fetch_as_grantee_depth8 p99 1.5ms 0.9ms -40.1% ok
share_cascade_rebac.fetch_as_grantee_depth_deep p50 0.5ms 0.4ms -17.3% ok
share_cascade_rebac.fetch_as_grantee_depth_deep p95 1.0ms 0.7ms -31.2% ok
share_cascade_rebac.fetch_as_grantee_depth_deep p99 1.6ms 0.8ms -49.4% ok
subject_group_nested.fetch_as_member_depth1 p50 0.5ms 0.4ms -8.4% ok
subject_group_nested.fetch_as_member_depth1 p95 0.6ms 0.6ms -10.2% ok
subject_group_nested.fetch_as_member_depth1 p99 1.4ms 0.6ms -55.2% ok
subject_group_nested.fetch_as_member_depth4 p50 0.5ms 0.5ms -7.9% ok
subject_group_nested.fetch_as_member_depth4 p95 0.6ms 0.6ms -4.0% ok
subject_group_nested.fetch_as_member_depth4 p99 0.7ms 0.6ms -2.2% ok
subject_group_nested.fetch_as_member_depth8 p50 0.5ms 0.4ms -8.9% ok
subject_group_nested.fetch_as_member_depth8 p95 0.5ms 0.6ms +7.1% ok
subject_group_nested.fetch_as_member_depth8 p99 0.6ms 0.7ms +10.3% ok
subject_group_nested.fetch_as_member_depth_deep p50 0.5ms 0.4ms -7.6% ok
subject_group_nested.fetch_as_member_depth_deep p95 0.6ms 0.5ms -11.9% ok
subject_group_nested.fetch_as_member_depth_deep p99 0.6ms 0.7ms +8.9% ok
This commit is contained in:
Executable
+166
@@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env bash
|
||||
# Full k6 load-test runner.
|
||||
# Starts postgres + OxiCloud server, seeds fixtures, runs k6 scenarios,
|
||||
# compares results against baseline/load.json, tears everything down.
|
||||
#
|
||||
# Usage (from repo root):
|
||||
# bash tests/load/run.sh
|
||||
#
|
||||
# Env overrides:
|
||||
# BUILD_TARGET=release # prefer release build for accurate timings
|
||||
# LOAD_DEPTH=8 # override seeder shape (otherwise read from test.env)
|
||||
# LOAD_FANOUT=3
|
||||
# LOAD_FILES_PER_LEAF=3
|
||||
# LOAD_EXTRA_USERS=20
|
||||
# LOAD_GROUP_DEPTH=3
|
||||
# LOAD_GROUP_FANOUT=5
|
||||
# K6_SUMMARY_OUT=path # explicit output path (default: tests/load/results/<ts>.json)
|
||||
#
|
||||
# Prerequisites: docker, cargo, k6 >= 0.46, node >= 18
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
COMMON="$REPO_ROOT/tests/common"
|
||||
LOAD_DIR="$REPO_ROOT/tests/load"
|
||||
|
||||
# shellcheck source=test.env
|
||||
source "$LOAD_DIR/test.env"
|
||||
|
||||
SERVER_PORT="${base_url##*:}"
|
||||
|
||||
log() { echo "[load] $*"; }
|
||||
die() { echo "[load] ERROR: $*" >&2; exit 1; }
|
||||
|
||||
wait_for_http() {
|
||||
local url="$1" timeout="${2:-120}"
|
||||
local deadline=$(( $(date +%s) + timeout ))
|
||||
until curl -sf "$url" >/dev/null 2>&1; do
|
||||
[[ $(date +%s) -ge $deadline ]] && die "Timeout waiting for $url"
|
||||
sleep 1
|
||||
done
|
||||
}
|
||||
|
||||
command -v k6 >/dev/null 2>&1 || die "k6 is required (https://k6.io/docs/get-started/installation/)"
|
||||
command -v node >/dev/null 2>&1 || die "node >= 18 is required for compare.mjs"
|
||||
|
||||
SERVER_PID=""
|
||||
cleanup() {
|
||||
if [[ -n "$SERVER_PID" ]]; then
|
||||
log "Stopping OxiCloud server (pid $SERVER_PID)..."
|
||||
kill "$SERVER_PID" 2>/dev/null || true
|
||||
wait "$SERVER_PID" 2>/dev/null || true
|
||||
fi
|
||||
bash "$COMMON/stop-db.sh" || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
bash "$COMMON/spawn-db.sh"
|
||||
|
||||
# NOTE: do NOT run init-test-schema.sh here. The OxiCloud server applies
|
||||
# sqlx migrations on startup; applying them via raw psql first leaves the
|
||||
# server's _sqlx_migrations tracking table empty, which makes the second
|
||||
# pass try to re-ALTER tables that already have the column (e.g. migration
|
||||
# 20260507000000_session_family.sql), and the server panics on boot.
|
||||
|
||||
set -a
|
||||
# shellcheck source=../common/server.env
|
||||
source "$COMMON/server.env"
|
||||
OXICLOUD_SERVER_PORT=$SERVER_PORT
|
||||
OXICLOUD_STORAGE_PATH="$LOAD_DIR/storage"
|
||||
set +a
|
||||
|
||||
rm -rf "$OXICLOUD_STORAGE_PATH"
|
||||
mkdir -p "$OXICLOUD_STORAGE_PATH"
|
||||
|
||||
BUILD_TARGET="${BUILD_TARGET:-release}"
|
||||
# Respect CARGO_TARGET_DIR for self-hosted runners that bind-mount target/
|
||||
# outside the workspace (avoids actions/checkout EBUSY on the mount point).
|
||||
TARGET_DIR="${CARGO_TARGET_DIR:-$REPO_ROOT/target}"
|
||||
OXICLOUD_BIN="$TARGET_DIR/$BUILD_TARGET/oxicloud"
|
||||
SEED_BIN="$TARGET_DIR/$BUILD_TARGET/load-seed"
|
||||
|
||||
# Build both bins in one invocation. `load_seed_bin` is an empty marker
|
||||
# feature that gates the load-seed bin without affecting oxicloud's dep
|
||||
# graph, so cargo plans a single workspace build and oxicloud compiles
|
||||
# exactly once. (See Cargo.toml comments on `load_seed_bin`.)
|
||||
if [[ ! -x "$OXICLOUD_BIN" || ! -x "$SEED_BIN" ]]; then
|
||||
log "Building OxiCloud + load-seed ($BUILD_TARGET)..."
|
||||
if [[ "$BUILD_TARGET" == "release" ]]; then
|
||||
cargo build --release --features load_seed_bin --bin oxicloud --bin load-seed
|
||||
else
|
||||
cargo build --features load_seed_bin --bin oxicloud --bin load-seed
|
||||
fi
|
||||
fi
|
||||
|
||||
# Start the server FIRST so its sqlx::migrate! populates _sqlx_migrations
|
||||
# against the clean DB. The seeder then runs against the migrated schema
|
||||
# while the server is still alive (it issues plain INSERTs, no DDL).
|
||||
log "Starting OxiCloud server ($BUILD_TARGET) on port $SERVER_PORT..."
|
||||
"$OXICLOUD_BIN" &
|
||||
SERVER_PID=$!
|
||||
wait_for_http "$base_url/ready" 120
|
||||
log "Server ready."
|
||||
|
||||
log "Seeding fixtures..."
|
||||
DEPTH="${LOAD_DEPTH:-${load_depth:-5}}"
|
||||
FANOUT="${LOAD_FANOUT:-${load_fanout:-4}}"
|
||||
FILES_PER_LEAF="${LOAD_FILES_PER_LEAF:-${load_files_per_leaf:-3}}"
|
||||
EXTRA_USERS="${LOAD_EXTRA_USERS:-${load_extra_users:-20}}"
|
||||
GROUP_DEPTH="${LOAD_GROUP_DEPTH:-${load_group_depth:-3}}"
|
||||
GROUP_FANOUT="${LOAD_GROUP_FANOUT:-${load_group_fanout:-5}}"
|
||||
|
||||
mkdir -p "$LOAD_DIR/results"
|
||||
MANIFEST_PATH="$LOAD_DIR/results/seed-manifest.json"
|
||||
|
||||
"$SEED_BIN" \
|
||||
--depth "$DEPTH" \
|
||||
--fanout "$FANOUT" \
|
||||
--files-per-leaf "$FILES_PER_LEAF" \
|
||||
--extra-users "$EXTRA_USERS" \
|
||||
--group-depth "$GROUP_DEPTH" \
|
||||
--group-fanout "$GROUP_FANOUT" \
|
||||
--password "${password:-TestPassword1!}" \
|
||||
--manifest "$MANIFEST_PATH"
|
||||
|
||||
TS="$(date +%s)"
|
||||
SUMMARY_OUT="${K6_SUMMARY_OUT:-$LOAD_DIR/results/run-$TS.json}"
|
||||
|
||||
export K6_BASE_URL="$base_url"
|
||||
export K6_USERNAME="${username:-admin}"
|
||||
export K6_PASSWORD="${password:-TestPassword1!}"
|
||||
|
||||
# k6 only accepts one script per invocation, so each scenario runs separately
|
||||
# and we merge the summaries afterwards. Per-scenario summaries also make it
|
||||
# easier to attribute regressions when looking at raw artifacts in CI.
|
||||
SCENARIOS=(folder_cascade share_cascade_rebac subject_group_nested)
|
||||
PARTIAL_SUMMARIES=()
|
||||
K6_FAILED=0
|
||||
|
||||
for name in "${SCENARIOS[@]}"; do
|
||||
partial="$LOAD_DIR/results/run-$TS-$name.json"
|
||||
PARTIAL_SUMMARIES+=("$partial")
|
||||
log "Running k6 scenario: $name"
|
||||
# --summary-trend-stats forces p(99) into the summary export; k6's default
|
||||
# only includes avg/min/med/max/p(90)/p(95), so without it baseline.p99 can
|
||||
# never be baked or diffed.
|
||||
k6 run \
|
||||
--summary-export="$partial" \
|
||||
--summary-trend-stats="avg,min,med,max,p(90),p(95),p(99)" \
|
||||
--quiet \
|
||||
"$LOAD_DIR/scenarios/$name.js" \
|
||||
|| K6_FAILED=$?
|
||||
done
|
||||
|
||||
log "Merging summaries -> $SUMMARY_OUT"
|
||||
node "$LOAD_DIR/merge-summaries.mjs" "${PARTIAL_SUMMARIES[@]}" "$SUMMARY_OUT"
|
||||
|
||||
log "Comparing against baseline..."
|
||||
COMPARE_RC=0
|
||||
node "$LOAD_DIR/compare.mjs" "$SUMMARY_OUT" "$LOAD_DIR/baseline/load.json" || COMPARE_RC=$?
|
||||
|
||||
if [[ "$K6_FAILED" -ne 0 ]]; then
|
||||
log "k6 reported threshold failures (exit $K6_FAILED)."
|
||||
exit "$K6_FAILED"
|
||||
fi
|
||||
exit "$COMPARE_RC"
|
||||
Reference in New Issue
Block a user