Files
vercel__workflow/scripts/event-log-race-repro-local.sh

386 lines
16 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# Run the event-log race repro harness locally, against @workflow/world-postgres
# and a workbench app started on this machine. No Vercel deployment, no GitHub
# Actions, no VERCEL_* credentials.
#
# This is the same harness `.github/workflows/event-log-race-repro.yml` runs
# (`packages/core/e2e/event-log-race-repro.test.ts`), pointed at a local backend.
# It exists because the ordering here is easy to get wrong in ways that fail
# silently rather than loudly:
#
# * WORKFLOW_PUBLIC_MANIFEST=1 must be set at *build* time — the Next.js builder
# emits the manifest as a static file during the build, and without it the
# harness cannot resolve workflow IDs.
# * WORKFLOW_TARGET_WORLD must be set at *build* time too. `withWorkflow()`
# defaults it to `local` when VERCEL_DEPLOYMENT_ID is absent, which bakes the
# filesystem backend into the app and leaves the harness talking to a
# different world than the app is.
# * The schema has to exist before the app boots, or Graphile Worker starts
# against an unmigrated database.
# * The queue has to be empty before the app boots. Postgres outlives the app
# here, so an interrupted run leaves its unfinished flow messages behind and
# the next boot resumes all of them — thousands of stale jobs starving the
# run you actually care about. See clear_queue below.
#
# Usage: scripts/event-log-race-repro-local.sh [options]
# Run with --help for options.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO_ROOT"
APP_NAME="nextjs-turbopack"
PORT="3000"
USE_DEV="0"
SKIP_BUILD="0"
SKIP_DB_SETUP="0"
USE_DOCKER="1"
TEARDOWN="0"
KEEP_QUEUE="0"
# In CI every replay of a run lands in its own Fluid invocation; here they all
# land in one Next.js process, and a storm run has tens of replays in flight at
# once, each holding a VM sandbox and its own copy of the event log. That is
# enough to exceed Node's default old-space limit (~4 GB) and take the server
# down mid-run with an OOM, which then shows up as unrelated `stuck` outcomes.
SERVER_HEAP_MB="8192"
# world-postgres runs an embedded Graphile Worker in every process that loads it,
# so the app and the harness each get this many slots. The package default is 50,
# i.e. ~100 replays in flight against one Next.js process. Measured on a 12-core
# laptop at the default: next-server pegged at ~120% CPU and 6.4 GB RSS against
# an 8 GB old space while Postgres sat idle (1 active connection), all 100 slots
# stayed locked, and every one of the 14 attempts came back `stuck` — GC
# saturation, not a database or a queue limit. Bounded, the same 14 attempts run
# to completion with no `stuck` at all. 10 is also world-postgres's pool size, so
# Graphile Worker stops warning that concurrency outruns maxPoolSize. Bounding
# replays per process does not weaken the repro: the race is between a handful of
# concurrent replays of one run, not a throughput effect.
LOCAL_WORKER_CONCURRENCY="10"
COMPOSE_FILE="packages/world-postgres/docker-compose.yaml"
# Matches the compose file and the `e2e-local-postgres` job in tests.yml.
DEFAULT_POSTGRES_URL="postgres://world:world@localhost:5432/world"
RESULTS_FILE="event-log-race-repro-results.json"
SERVER_LOG="event-log-race-repro-server.log"
RENDERER=".github/scripts/render-event-log-race-repro-results.js"
usage() {
cat <<'EOF'
Run the event-log race repro harness against world-postgres + a local workbench app.
Options:
--app NAME Workbench app to drive (default: nextjs-turbopack).
The repro workflow fixtures (101_hook_sleep_repro.ts,
103_event_log_corruption_repro.ts) only exist in
workbench/nextjs-turbopack; another app needs them copied
or symlinked in first.
--port N Port for the app (default: 3000).
--heap-mb N Old-space limit for the app process (default: 8192). The
server holds every concurrent replay of every run, so the
default heap is not enough; lower this only if the machine
cannot spare it, and expect OOM-induced `stuck` outcomes.
--dev Use `pnpm dev` instead of a production build + `pnpm start`.
Faster to iterate on workflow fixtures; less like CI.
--skip-build Skip `pnpm build` and the app build. Use when only the
harness or the driver changed.
--skip-db-setup Skip applying migrations (schema already set up).
--no-docker Do not manage Postgres. Point WORKFLOW_POSTGRES_URL at your
own instance; the schema still needs to exist.
--keep-queue Leave queued Graphile Worker jobs in place. By default they
are deleted before the app boots: an interrupted earlier run
leaves its flow messages queued, and resuming those abandoned
runs saturates the app so this run reports `stuck` for
reasons that have nothing to do with the event log. Only
useful if you are inspecting the leftovers themselves.
--teardown Stop and delete the Postgres container on exit. Off by
default so repeat runs skip container startup.
-h, --help Show this help.
Scale knobs are read from the environment by the harness itself, so anything
already exported is passed through untouched:
EVENT_LOG_RACE_REPRO_STEP_STORM_ATTEMPTS EVENT_LOG_RACE_REPRO_ROUNDS
EVENT_LOG_RACE_REPRO_HOOK_STORM_ATTEMPTS EVENT_LOG_RACE_REPRO_WIDTH
EVENT_LOG_RACE_REPRO_ATTEMPTS EVENT_LOG_RACE_REPRO_WATCHDOG_MS
EVENT_LOG_RACE_REPRO_CONCURRENCY EVENT_LOG_RACE_REPRO_STEP_DELAY_MS
EVENT_LOG_RACE_REPRO_BUDGET_MS EVENT_LOG_RACE_REPRO_POKE_INTERVAL_MS
EVENT_LOG_RACE_REPRO_RUN_TIMEOUT_MS EVENT_LOG_RACE_REPRO_HOOK_RESUME_STAGGER_MS
This script deliberately sets none of them. Their defaults — and the full list —
live in packages/core/e2e/event-log-race-repro.test.ts, which is the single
source of truth the CI workflow also defers to.
The one knob this script does set is WORKFLOW_POSTGRES_WORKER_CONCURRENCY, which
caps how many replays the app and the harness each run at once. world-postgres
defaults it to 50, i.e. ~100 replays sharing one Next.js process, which on a
12-core laptop saturates GC and reports all 14 attempts as `stuck`. It is set to
10 here instead, matching the pool size. Export your own value to override.
Examples:
# Default scale (a regression check, a few minutes).
scripts/event-log-race-repro-local.sh
# One run per scenario, to smoke the plumbing.
EVENT_LOG_RACE_REPRO_STEP_STORM_ATTEMPTS=1 \
EVENT_LOG_RACE_REPRO_HOOK_STORM_ATTEMPTS=1 \
EVENT_LOG_RACE_REPRO_ATTEMPTS=1 \
scripts/event-log-race-repro-local.sh
# Soak for a rate, reusing an already-built app and database.
EVENT_LOG_RACE_REPRO_STEP_STORM_ATTEMPTS=600 \
EVENT_LOG_RACE_REPRO_HOOK_STORM_ATTEMPTS=600 \
EVENT_LOG_RACE_REPRO_ATTEMPTS=200 \
EVENT_LOG_RACE_REPRO_CONCURRENCY=40 \
EVENT_LOG_RACE_REPRO_BUDGET_MS=4500000 \
scripts/event-log-race-repro-local.sh --skip-build --skip-db-setup
EOF
}
while [ $# -gt 0 ]; do
case "$1" in
--app) APP_NAME="${2:?--app needs a value}"; shift 2 ;;
--port) PORT="${2:?--port needs a value}"; shift 2 ;;
--heap-mb) SERVER_HEAP_MB="${2:?--heap-mb needs a value}"; shift 2 ;;
--dev) USE_DEV="1"; shift ;;
--skip-build) SKIP_BUILD="1"; shift ;;
--skip-db-setup) SKIP_DB_SETUP="1"; shift ;;
--no-docker) USE_DOCKER="0"; shift ;;
--keep-queue) KEEP_QUEUE="1"; shift ;;
--teardown) TEARDOWN="1"; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown option: $1" >&2; echo >&2; usage >&2; exit 2 ;;
esac
done
log() { printf '\n\033[1m[repro]\033[0m %s\n' "$*"; }
die() { printf '\n\033[1;31m[repro]\033[0m %s\n' "$*" >&2; exit 1; }
APP_DIR="workbench/$APP_NAME"
[ -d "$APP_DIR" ] || die "No such workbench app: $APP_DIR"
# The harness resolves these two workflow files through the deployment's
# manifest, so an app without them fails one attempt at a time with opaque
# harness errors instead of up front.
for fixture in 101_hook_sleep_repro.ts 103_event_log_corruption_repro.ts; do
[ -e "$APP_DIR/workflows/$fixture" ] ||
die "$APP_DIR/workflows/$fixture is missing — the repro fixtures live in workbench/nextjs-turbopack."
done
export WORKFLOW_POSTGRES_URL="${WORKFLOW_POSTGRES_URL:-$DEFAULT_POSTGRES_URL}"
export WORKFLOW_TARGET_WORLD="@workflow/world-postgres"
export WORKFLOW_PUBLIC_MANIFEST="1"
export WORKFLOW_POSTGRES_WORKER_CONCURRENCY="${WORKFLOW_POSTGRES_WORKER_CONCURRENCY:-$LOCAL_WORKER_CONCURRENCY}"
export PORT="$PORT"
DEPLOYMENT_URL="http://localhost:$PORT"
MANIFEST_URL="$DEPLOYMENT_URL/.well-known/workflow/v1/manifest.json"
# --- port ---------------------------------------------------------------------
port_pids() { lsof -ti "tcp:$PORT" 2>/dev/null || true; }
if [ -n "$(port_pids)" ]; then
die "Port $PORT is already in use (pids: $(port_pids | tr '\n' ' ')). Stop it or pass --port."
fi
# --- cleanup ------------------------------------------------------------------
SERVER_PID=""
cleanup() {
local status=$?
set +e
if [ -n "$SERVER_PID" ]; then
log "Stopping the app (pid $SERVER_PID)"
# `pnpm start` is a wrapper around `next start`, so the pnpm pid is not the
# listener. Kill the wrapper, then its children, then anything still holding
# the port — a process-group kill is not portable here (macOS has no setsid).
pkill -P "$SERVER_PID" >/dev/null 2>&1
kill "$SERVER_PID" >/dev/null 2>&1
# Then wait for the port to actually come free rather than just asking. The
# listener takes a moment to unbind, and the next run's up-front port check
# runs long before that — back-to-back runs otherwise die on "port in use".
local pids i
for i in 1 2 3 4 5 6 7 8 9 10; do
pids="$(port_pids)"
[ -n "$pids" ] || break
# SIGTERM first, SIGKILL from the third try on.
if [ "$i" -lt 3 ]; then
echo "$pids" | xargs kill >/dev/null 2>&1
else
echo "$pids" | xargs kill -9 >/dev/null 2>&1
fi
sleep 1
done
[ -z "$(port_pids)" ] ||
log "Port $PORT is still held by $(port_pids | tr '\n' ' ')— the next run needs --port or a manual kill."
fi
if [ "$TEARDOWN" = "1" ] && [ "$USE_DOCKER" = "1" ]; then
log "Removing the Postgres container"
docker compose -f "$COMPOSE_FILE" down -v >/dev/null 2>&1
fi
set -e
return $status
}
trap cleanup EXIT
# --- postgres -----------------------------------------------------------------
if [ "$USE_DOCKER" = "1" ]; then
command -v docker >/dev/null 2>&1 || die "docker not found. Install Docker, or use --no-docker with your own Postgres."
log "Starting Postgres ($COMPOSE_FILE)"
docker compose -f "$COMPOSE_FILE" up -d
log "Waiting for Postgres to accept connections"
for _ in $(seq 1 60); do
if docker compose -f "$COMPOSE_FILE" exec -T postgres pg_isready -U world -d world >/dev/null 2>&1; then
break
fi
sleep 1
done
docker compose -f "$COMPOSE_FILE" exec -T postgres pg_isready -U world -d world >/dev/null 2>&1 ||
die "Postgres did not become ready. Check: docker compose -f $COMPOSE_FILE logs postgres"
else
log "Using the Postgres at WORKFLOW_POSTGRES_URL (not managed by this script)"
fi
# --- queue ---------------------------------------------------------------------
# Runs a single statement as the `world` user. Prefers the container's own psql so
# no local Postgres client is required.
pg_exec() {
if [ "$USE_DOCKER" = "1" ]; then
docker compose -f "$COMPOSE_FILE" exec -T postgres psql -U world -d world -At -c "$1" 2>/dev/null
else
psql "$WORKFLOW_POSTGRES_URL" -At -c "$1" 2>/dev/null
fi
}
# The jobs table is private to graphile-worker and has been renamed across its
# versions, so resolve it instead of hardcoding one name, and no-op when the
# schema does not exist yet (a fresh database).
QUEUE_TABLE_SQL="select coalesce(to_regclass('graphile_worker._private_jobs')::text, to_regclass('graphile_worker.jobs')::text, '')"
clear_queue() {
local table count
table="$(pg_exec "$QUEUE_TABLE_SQL" || true)"
[ -n "$table" ] || return 0
count="$(pg_exec "select count(*) from $table" || echo "0")"
[ "${count:-0}" -gt 0 ] || return 0
pg_exec "delete from $table" >/dev/null ||
die "Could not clear $table. Pass --keep-queue to skip this, or reset the database."
# Loud on purpose: a backlog here means the previous run was interrupted, which
# is worth knowing when comparing results between runs.
log "Discarded $count queued job(s) left over from an earlier run ($table)"
}
if [ "$KEEP_QUEUE" = "0" ]; then
if [ "$USE_DOCKER" = "0" ] && ! command -v psql >/dev/null 2>&1; then
log "psql not found — leaving the queue as it is. Stale jobs from an interrupted run will compete with this one."
else
clear_queue
fi
fi
# --- build --------------------------------------------------------------------
if [ "$SKIP_BUILD" = "0" ]; then
# Turbo-cached, so this is cheap on repeat runs. Needed before the migration
# step, which loads packages/world-postgres/dist/cli.js.
log "Building packages"
pnpm build
fi
if [ "$SKIP_DB_SETUP" = "0" ]; then
log "Applying the world-postgres schema"
./packages/world-postgres/bin/setup.js
fi
if [ "$SKIP_BUILD" = "0" ] && [ "$USE_DEV" = "0" ]; then
# WORKFLOW_PUBLIC_MANIFEST and WORKFLOW_TARGET_WORLD are exported above, so
# they apply to this build as well as to the server started below. Both are
# build-time inputs — see the header comment.
log "Building $APP_NAME"
pnpm --filter "$APP_NAME" build
fi
# --- server -------------------------------------------------------------------
if [ "$USE_DEV" = "1" ]; then
SERVER_CMD="dev"
else
SERVER_CMD="start"
fi
log "Starting $APP_NAME ($SERVER_CMD) on $DEPLOYMENT_URL — logging to $SERVER_LOG"
: > "$SERVER_LOG"
(
cd "$APP_DIR" &&
NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--max-old-space-size=$SERVER_HEAP_MB" \
pnpm "$SERVER_CMD" >> "$REPO_ROOT/$SERVER_LOG" 2>&1
) &
SERVER_PID=$!
# A 200 on the manifest proves both that the server is up and that the public
# manifest the harness depends on was actually emitted — a build without
# WORKFLOW_PUBLIC_MANIFEST=1 serves the app fine and 404s here.
log "Waiting for the workflow manifest at $MANIFEST_URL"
ready="0"
for _ in $(seq 1 180); do
if ! kill -0 "$SERVER_PID" 2>/dev/null; then
tail -40 "$SERVER_LOG" >&2
die "The app exited before serving the manifest. Full log: $SERVER_LOG"
fi
if curl -fsS -o /dev/null "$MANIFEST_URL" 2>/dev/null; then
ready="1"
break
fi
sleep 1
done
[ "$ready" = "1" ] || {
tail -40 "$SERVER_LOG" >&2
die "Timed out waiting for $MANIFEST_URL. Full log: $SERVER_LOG"
}
# --- harness ------------------------------------------------------------------
log "Running the repro harness"
rm -f "$RESULTS_FILE"
set +e
DEPLOYMENT_URL="$DEPLOYMENT_URL" \
APP_NAME="$APP_NAME" \
NODE_OPTIONS="--enable-source-maps" \
pnpm run test:e2e:event-log-race-repro --reporter=default
harness_status=$?
set -e
# A server that died mid-run makes every remaining attempt time out, so the
# harness reports a wall of `stuck` runs with no hint of why. Say so plainly —
# an OOM or a crash here is a local-environment problem, not a repro.
if ! kill -0 "$SERVER_PID" 2>/dev/null; then
log "The app is no longer running — it died during the harness. Last lines:"
tail -20 "$SERVER_LOG" >&2
SERVER_PID=""
harness_status=1
fi
# --- summary ------------------------------------------------------------------
if [ -f "$RESULTS_FILE" ]; then
log "Summary"
# Same renderer the CI job uses for its sticky comment; with no --run-url it
# just prints the tables.
node "$RENDERER" "$RESULTS_FILE"
log "Full results: $RESULTS_FILE / app log: $SERVER_LOG"
else
log "No $RESULTS_FILE was written — the harness died before its first checkpoint."
fi
if [ "$USE_DOCKER" = "1" ] && [ "$TEARDOWN" = "0" ]; then
log "Postgres is still running. Stop it with: docker compose -f $COMPOSE_FILE down -v"
fi
# The harness' own exit code is the gate: it fails on any non-`completed`,
# non-`infra` outcome, which is the same rule as the renderer's --check.
exit "$harness_status"