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

498 lines
22 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# Run the event-log race repro harness locally, against a workbench app started
# on this machine and either @workflow/world-postgres (the default) or
# @workflow/world-local (--world local). 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.
# * (postgres) The schema has to exist before the app boots, or Graphile Worker
# starts against an unmigrated database.
# * The backend's pending work has to be empty before the app boots. Both
# backends outlive the app here, so an interrupted run leaves its unfinished
# flow messages behind and the next boot resumes all of them — stale work
# starving the run you actually care about. See clear_pending below.
#
# Both worlds are worth running. They fail differently, and neither subsumes the
# other: world-postgres arbitrates event slots inside one SQL statement, while
# world-local arbitrates them with an exclusive `link(2)` against a directory
# that two processes (the app and this harness) both write to. A slot race that
# a transaction closes is not automatically closed by a filesystem.
#
# 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"
# Which World the app and the harness both talk to: `postgres` or `local`.
WORLD="postgres"
# 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"
# The world-local equivalent. Its queue is in-process timers driving HTTP
# deliveries, and it defaults to 1000 in flight, which saturates the one
# Next.js process the same way the Graphile default does. Held at the same
# number as the postgres lane so the two are comparable.
LOCAL_QUEUE_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 a local workbench app, backed by
either world-postgres (default) or world-local.
Options:
--world NAME Backend World: `postgres` (default) or `local`.
`local` needs no Docker and no database: the app and this
harness share the app's filesystem data directory, and
--skip-db-setup / --no-docker / --teardown are ignored.
The two worlds arbitrate event slots by different means
(one SQL statement vs. an exclusive link(2) between two
processes), so a clean run on one says nothing about the
other.
--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 (postgres) Skip applying migrations (schema already set up).
--no-docker (postgres) Do not manage Postgres. Point WORKFLOW_POSTGRES_URL
at your own instance; the schema still needs to exist.
--keep-queue Leave the backend's pending work in place. By default it is
discarded before the app boots: an interrupted earlier run
leaves its flow messages queued (postgres) or its runs
pending in the data directory (local), 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 (postgres) 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_POKE_MAX
EVENT_LOG_RACE_REPRO_HOOK_RESUME_STAGGER_MS
This script sets exactly one of them, `EVENT_LOG_RACE_REPRO_RUN_TIMEOUT_MS`,
and only because that knob does not mean the same thing here as it does on the
Vercel lane. The same storm takes ~2x as long against a local World — one Node
process serves every replay of every run, where Vercel spreads them across Fluid
instances — so `step-storm` lands at ~200s against the harness' 240s default,
i.e. 83% of its own timeout, and one slow runner turns a lane that reproduces
into a lane full of `stuck`. It is raised to 480000 here, and anything you export
wins. Every other default — and the full list — lives in
packages/core/e2e/event-log-race-repro.test.ts, which is the single source of
truth the CI workflow also defers to.
The other knob this script sets is the backend's replay concurrency, which
caps how many replays the app and the harness each run at once. Both backends
default it far too high for one Next.js process on one machine (50 for
world-postgres, i.e. ~100 replays in flight; 1000 for world-local), which on a
12-core laptop saturates GC and reports every attempt as `stuck`. Both are set
to 10 here. Export your own value to override:
WORKFLOW_POSTGRES_WORKER_CONCURRENCY (--world postgres)
WORKFLOW_LOCAL_QUEUE_CONCURRENCY (--world local)
Examples:
# Default scale (a regression check, a few minutes).
scripts/event-log-race-repro-local.sh
# Same, against world-local. No Docker, no database.
scripts/event-log-race-repro-local.sh --world local
# 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
--world) WORLD="${2:?--world needs a value}"; shift 2 ;;
--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
case "$WORLD" in
postgres|local) ;;
*) die "Unknown --world: $WORLD (expected \`postgres\` or \`local\`)" ;;
esac
export WORKFLOW_PUBLIC_MANIFEST="1"
# See the note in usage: a local lane runs the same storm ~2x slower than the
# Vercel lane, so the harness' own 240000 leaves `step-storm` at ~83% of its
# timeout and one slow runner turns "reproduces" into "stuck". Measured on
# GitHub's 4-core runners at the default scale: world-local `step-storm`
# 194-203s, world-postgres 168-175s, against Vercel's 85-100s.
export EVENT_LOG_RACE_REPRO_RUN_TIMEOUT_MS="${EVENT_LOG_RACE_REPRO_RUN_TIMEOUT_MS:-480000}"
if [ "$WORLD" = "postgres" ]; then
export WORKFLOW_POSTGRES_URL="${WORKFLOW_POSTGRES_URL:-$DEFAULT_POSTGRES_URL}"
export WORKFLOW_TARGET_WORLD="@workflow/world-postgres"
export WORKFLOW_POSTGRES_WORKER_CONCURRENCY="${WORKFLOW_POSTGRES_WORKER_CONCURRENCY:-$LOCAL_WORKER_CONCURRENCY}"
else
# Postgres is a service both processes address by URL; world-local is a
# directory both processes address by path, so the path has to be pinned
# explicitly and identically or the two silently use different backends.
#
# `withWorkflow()` pairs its `local` default with `.next/workflow-data`, but
# only when WORKFLOW_TARGET_WORLD is unset — naming the world here suppresses
# the data-dir half of that pair, and the app would fall back to
# `.workflow-data` while the harness (`setupWorld` in packages/core/e2e/utils.ts)
# went on using `.next/workflow-data`. Both halves are therefore set here, as
# an absolute path so the app's cwd does not enter into it. `setupWorld`
# recomputes the same path for the harness process.
# Substring matches, not prefixes: `setupWorld` selects on
# `appName.includes('nextjs') || appName.includes('next-')`, so an app named
# `example-nextjs` has to land on the same branch here. A prefix glob would
# send the app to `.workflow-data` and the harness to `.next/workflow-data`,
# which is the split-brain this block exists to prevent.
case "$APP_NAME" in
*nextjs*|*next-*) DATA_DIR_NAME=".next/workflow-data" ;;
*) DATA_DIR_NAME=".workflow-data" ;;
esac
DATA_DIR="$REPO_ROOT/$APP_DIR/$DATA_DIR_NAME"
export WORKFLOW_TARGET_WORLD="local"
export WORKFLOW_LOCAL_DATA_DIR="$DATA_DIR"
export WORKFLOW_LOCAL_QUEUE_CONCURRENCY="${WORKFLOW_LOCAL_QUEUE_CONCURRENCY:-$LOCAL_QUEUE_CONCURRENCY}"
fi
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 [ "$WORLD" = "postgres" ] && [ "$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 [ "$WORLD" != "postgres" ]; then
log "Backend: world-local at $WORKFLOW_LOCAL_DATA_DIR"
elif [ "$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"
# Probe over TCP, not the unix socket. The image's entrypoint runs a temporary
# server for initdb with `listen_addresses=''`, so a socket probe can answer
# "ready" during initialization and then answer "not ready" a moment later,
# while that server is being swapped for the real one. TCP is only open once
# the real server is up, and it is what the app connects over anyway.
postgres_ready=0
for _ in $(seq 1 60); do
if docker compose -f "$COMPOSE_FILE" exec -T postgres \
pg_isready -h 127.0.0.1 -U world -d world >/dev/null 2>&1; then
postgres_ready=1
break
fi
sleep 1
done
[ "$postgres_ready" = "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
# --- pending work ---------------------------------------------------------------
# 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)"
}
# world-local's queue is in-process, so nothing survives the app exiting — but
# the runs do. `start()` re-enqueues every pending/running run it finds in the
# data directory on boot (see resolveRecoverActiveRuns), so an interrupted
# earlier run resumes its abandoned storms alongside this one. Deleting the
# directory is the equivalent of emptying the jobs table, and it also keeps the
# per-run event files from accumulating across runs, which slows every
# directory scan the slot allocator makes.
clear_data_dir() {
[ -d "$WORKFLOW_LOCAL_DATA_DIR" ] || return 0
local runs
runs="$(find "$WORKFLOW_LOCAL_DATA_DIR/runs" -name '*.json' 2>/dev/null | wc -l | tr -d ' ')"
rm -rf "$WORKFLOW_LOCAL_DATA_DIR" ||
die "Could not remove $WORKFLOW_LOCAL_DATA_DIR. Pass --keep-queue to skip this."
[ "${runs:-0}" -gt 0 ] || return 0
# Loud on purpose: a backlog here means the previous run was interrupted, which
# is worth knowing when comparing results between runs.
log "Discarded $runs run(s) left over from an earlier run ($WORKFLOW_LOCAL_DATA_DIR)"
}
if [ "$KEEP_QUEUE" = "0" ]; then
if [ "$WORLD" = "local" ]; then
clear_data_dir
elif [ "$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 [ "$WORLD" = "postgres" ] && [ "$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
# prints the tables without the comment marker or the stored history.
node "$RENDERER" "$RESULTS_FILE" --label "world-$WORLD"
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 [ "$WORLD" = "postgres" ] && [ "$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"