Add db-migrations-expand-contract: zero-downtime schema migrations proven with a real 4-deploy rolling run

Companion code for Zero-Downtime Database Migrations: Expand-Contract in Practice
with Spring Boot: a full expand/migrate-writes/migrate-reads/contract sequence run
as an actual rolling deploy across two live replicas, with a load generator sending
continuous HTTP traffic through all four deploys (99.98% success, every residual
error traced to a root cause rather than left unexplained). Findings include a real
NOT NULL constraint trap in the expand migration, a backfill-window bug in the read
switch, H2's AUTO_SERVER=TRUE single-point-of-failure behavior under a rolling
restart, the drain-before-SIGTERM fix needed to close a health-check gap during
graceful shutdown, and H2 silently discarding a concurrently committed INSERT during
an ALTER TABLE ADD/DROP COLUMN rebuild - confirmed, by primary source, to be an
H2-specific behavior rather than a property of the technique itself.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_019Fb7vW8vLyLKngBc4R3huA
This commit is contained in:
2026-09-16 19:22:48 +00:00
co-authored by Claude Sonnet 5
parent 22c30d4a5b
commit e478eafda3
60 changed files with 3561 additions and 0 deletions
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env bash
# Shared paths and settings for every script in this directory. Source this, don't run it.
export EC_DB_TCP_PORT="${EC_DB_TCP_PORT:-9092}"
export EC_DB_NAME="${EC_DB_NAME:-expand-contract}"
EC_DB_BASE_DIR="${EC_DB_BASE_DIR:-/tmp/ec-demo/db}"
EC_RUN_DIR="${EC_RUN_DIR:-/tmp/ec-demo}"
EC_LOG_DIR="$EC_RUN_DIR/logs"
EC_PID_DIR="$EC_RUN_DIR/pids"
EC_PHASE_FILE="$EC_RUN_DIR/phase.txt"
EC_LOAD_SUMMARY="$EC_RUN_DIR/load-summary.txt"
PORT_A=8081
PORT_B=8082
MODULE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
JAR="$MODULE_DIR/target/db-migrations-expand-contract-1.0.0.jar"
CP_FILE="$MODULE_DIR/cp.txt"
mkdir -p "$EC_LOG_DIR" "$EC_PID_DIR" "$EC_DB_BASE_DIR"
wait_db_server() {
local tries="${1:-40}"
for i in $(seq 1 "$tries"); do
if (echo > "/dev/tcp/localhost/$EC_DB_TCP_PORT") >/dev/null 2>&1; then
return 0
fi
sleep 0.25
done
echo "H2 TCP server on port $EC_DB_TCP_PORT never came up" >&2
return 1
}
set_phase() {
echo -n "$1" > "$EC_PHASE_FILE"
echo "[phase] $1"
}
wait_healthy() {
local port="$1"
local tries="${2:-40}"
for i in $(seq 1 "$tries"); do
code=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:$port/actuator/health" || true)
if [ "$code" = "200" ]; then
return 0
fi
sleep 0.5
done
echo "instance on port $port never became healthy" >&2
return 1
}
wait_down() {
local port="$1"
local tries="${2:-20}"
for i in $(seq 1 "$tries"); do
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 1 "http://localhost:$port/actuator/health" || true)
if [ "$code" != "200" ]; then
return 0
fi
sleep 0.3
done
return 1
}
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
# Runs the standalone migration.MigrationCli against the live database - no app
# restart, no app deploy. Usage: migrate.sh <target|latest>
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$DIR/env.sh"
TARGET="${1:-latest}"
java -cp "$MODULE_DIR/target/classes:$(cat "$CP_FILE")" \
com.ankurm.expandcontract.migration.MigrationCli --target="$TARGET"
+145
View File
@@ -0,0 +1,145 @@
#!/usr/bin/env bash
# The live exhibit: two replicas, a shared H2 database, and a load generator that
# never stops sending traffic while this script performs all four deploys of the
# expand-contract sequence as an actual rolling deploy - one replica at a time.
#
# Regenerates:
# docs/output/11-live-deploy-sequence.txt - the deploy log, phase by phase
# docs/output/12-load-generator-summary.txt - total/ok/error counts, by phase
# docs/output/13-schema-diagnostics-timeline.txt - /diag/schema after each deploy
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$DIR/env.sh"
OUTPUT_DIR="$MODULE_DIR/docs/output"
mkdir -p "$OUTPUT_DIR"
DEPLOY_LOG="$OUTPUT_DIR/11-live-deploy-sequence.txt"
DIAG_LOG="$OUTPUT_DIR/13-schema-diagnostics-timeline.txt"
log() {
echo "$(date -u +%H:%M:%S) $*" | tee -a "$DEPLOY_LOG"
}
diag() {
local label="$1"
{
echo ""
echo "-- $label --"
curl -s "http://localhost:$PORT_A/diag/schema"
echo ""
} | tee -a "$DIAG_LOG"
}
# --- clean slate ---
for p in "$PORT_A" "$PORT_B"; do
"$DIR/stop-instance.sh" "$p" 2>/dev/null || true
done
if [ -f "$EC_PID_DIR/loadgen.pid" ]; then
kill -9 "$(cat "$EC_PID_DIR/loadgen.pid")" 2>/dev/null || true
rm -f "$EC_PID_DIR/loadgen.pid"
fi
"$DIR/stop-db-server.sh" 2>/dev/null || true
rm -rf "$EC_DB_BASE_DIR"
mkdir -p "$EC_DB_BASE_DIR"
rm -f "$DEPLOY_LOG" "$DIAG_LOG"
echo "=====================================================================" > "$DEPLOY_LOG"
echo "Zero-downtime expand-contract: live 4-deploy sequence" >> "$DEPLOY_LOG"
echo "=====================================================================" >> "$DEPLOY_LOG"
log "captured: $(date -u +%FT%TZ)"
# --- Deploy -1: the database itself. This process is never restarted for the rest
# of this script - it is the one thing every deploy below has to treat as always up.
log "Starting the database as its own standalone process (not owned by either replica)"
"$DIR/start-db-server.sh" | tee -a "$DEPLOY_LOG"
# --- Deploy 0: baseline schema + two Stage 1 replicas ---
log "Deploy 0: create schema (V1), start two Stage 1 replicas"
"$DIR/migrate.sh" 1 | tee -a "$DEPLOY_LOG" > /dev/null
"$DIR/start-instance.sh" "$PORT_A" 1 | tee -a "$DEPLOY_LOG"
"$DIR/start-instance.sh" "$PORT_B" 1 | tee -a "$DEPLOY_LOG"
diag "after Deploy 0 (Stage 1 / Stage 1)"
"$DIR/run-load-generator.sh" 120
log "load generator running against both replicas"
sleep 10
log "baseline soak complete (10s, both replicas on Stage 1)"
# --- Deploy 1 (EXPAND): schema only, zero app restarts ---
set_phase "01-expand-migration"
log "Deploy 1 (EXPAND): migrating to V2 live - zero app restarts"
"$DIR/migrate.sh" 2 | tee -a "$DEPLOY_LOG" > /dev/null
diag "after Deploy 1 (schema expanded, both replicas still Stage 1)"
sleep 5
# --- Deploy 2 (MIGRATE WRITES): rolling restart to Stage 2 ---
set_phase "02-deploy-stage2-rollout"
log "Deploy 2 (MIGRATE WRITES): rolling restart to Stage 2, replica A first"
"$DIR/stop-instance.sh" "$PORT_A" | tee -a "$DEPLOY_LOG"
"$DIR/start-instance.sh" "$PORT_A" 2 | tee -a "$DEPLOY_LOG"
sleep 2
log "Deploy 2: replica B"
"$DIR/stop-instance.sh" "$PORT_B" | tee -a "$DEPLOY_LOG"
"$DIR/start-instance.sh" "$PORT_B" 2 | tee -a "$DEPLOY_LOG"
set_phase "02-stage2-soak"
diag "after Deploy 2 (both replicas Stage 2, dual-write live)"
sleep 8
# --- Deploy 3 (MIGRATE READS): rolling restart to Stage 3 ---
set_phase "03-deploy-stage3-rollout"
log "Deploy 3 (MIGRATE READS): rolling restart to Stage 3, replica A first"
"$DIR/stop-instance.sh" "$PORT_A" | tee -a "$DEPLOY_LOG"
"$DIR/start-instance.sh" "$PORT_A" 3 | tee -a "$DEPLOY_LOG"
sleep 2
log "Deploy 3: replica B"
"$DIR/stop-instance.sh" "$PORT_B" | tee -a "$DEPLOY_LOG"
"$DIR/start-instance.sh" "$PORT_B" 3 | tee -a "$DEPLOY_LOG"
set_phase "03-stage3-soak"
diag "after Deploy 3 (both replicas Stage 3, reading email_address)"
sleep 8
# --- Deploy 4a (CONTRACT, code): rolling restart to Stage 4 ---
set_phase "04a-deploy-stage4-rollout"
log "Deploy 4a (CONTRACT code): rolling restart to Stage 4, replica A first"
"$DIR/stop-instance.sh" "$PORT_A" | tee -a "$DEPLOY_LOG"
"$DIR/start-instance.sh" "$PORT_A" 4 | tee -a "$DEPLOY_LOG"
sleep 2
log "Deploy 4a: replica B"
"$DIR/stop-instance.sh" "$PORT_B" | tee -a "$DEPLOY_LOG"
"$DIR/start-instance.sh" "$PORT_B" 4 | tee -a "$DEPLOY_LOG"
set_phase "04a-stage4-soak"
diag "after Deploy 4a (both replicas Stage 4, email column still present but unused)"
sleep 8
# --- Deploy 4b (CONTRACT, schema): drop the old column, live ---
set_phase "04b-contract-migration"
log "Deploy 4b (CONTRACT schema): migrating to V3 live - drops \"email\", zero app restarts"
"$DIR/migrate.sh" latest | tee -a "$DEPLOY_LOG" > /dev/null
diag "after Deploy 4b (email column dropped)"
set_phase "05-final-soak"
sleep 10
log "final soak complete"
log "waiting for the load generator to finish its run..."
if [ -f "$EC_PID_DIR/loadgen.pid" ]; then
LOADGEN_PID="$(cat "$EC_PID_DIR/loadgen.pid")"
while kill -0 "$LOADGEN_PID" 2>/dev/null; do
sleep 1
done
fi
log "load generator finished"
cp "$EC_LOAD_SUMMARY" "$OUTPUT_DIR/12-load-generator-summary.txt"
for p in "$PORT_A" "$PORT_B"; do
"$DIR/stop-instance.sh" "$p" || true
done
"$DIR/stop-db-server.sh" || true
log "=== deploy sequence complete ==="
echo ""
echo "Deploy log: $DEPLOY_LOG"
echo "Load summary: $OUTPUT_DIR/12-load-generator-summary.txt"
echo "Schema timeline: $DIAG_LOG"
cat "$OUTPUT_DIR/12-load-generator-summary.txt"
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# Starts the load generator against both replica ports for a fixed duration and
# returns immediately - it runs in the background for the rest of run-all.sh.
# Usage: run-load-generator.sh <durationSeconds>
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$DIR/env.sh"
DURATION="${1:-170}"
rm -f "$EC_PHASE_FILE" "$EC_LOAD_SUMMARY"
set_phase "00-baseline-soak"
nohup java -cp "$MODULE_DIR/target/classes:$(cat "$CP_FILE")" \
com.ankurm.expandcontract.loadgen.LoadGenerator \
--ports="$PORT_A,$PORT_B" \
--durationSeconds="$DURATION" \
--phaseFile="$EC_PHASE_FILE" \
--outFile="$EC_LOAD_SUMMARY" \
--threads=8 \
> "$EC_LOG_DIR/load-generator.log" 2>&1 < /dev/null &
echo $! > "$EC_PID_DIR/loadgen.pid"
echo "load generator started (pid $(cat "$EC_PID_DIR/loadgen.pid")), running for ${DURATION}s"
+16
View File
@@ -0,0 +1,16 @@
#!/usr/bin/env bash
# Starts H2 as its own standalone TCP server process - not owned by, or co-located
# inside, either app replica. This is what a real production database is: a process
# that outlives every app deploy. See docs/12-the-auto-server-trap.md for what went
# wrong the first time this demo shared a database file directly between the two
# replicas instead.
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$DIR/env.sh"
nohup java -cp "$(cat "$CP_FILE")" org.h2.tools.Server \
-tcp -tcpPort "$EC_DB_TCP_PORT" -baseDir "$EC_DB_BASE_DIR" -ifNotExists \
> "$EC_LOG_DIR/db-server.log" 2>&1 < /dev/null &
echo $! > "$EC_PID_DIR/db-server.pid"
wait_db_server
echo "H2 TCP server up on port $EC_DB_TCP_PORT (pid $(cat "$EC_PID_DIR/db-server.pid")), baseDir $EC_DB_BASE_DIR"
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
# Starts one replica on a given port and stage, and waits for it to report healthy.
# Usage: start-instance.sh <port> <stage>
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$DIR/env.sh"
PORT="$1"
STAGE="$2"
# -XX:TieredStopAtLevel=1 and a small fixed heap cut this JVM's own startup CPU
# burst dramatically (C2 compilation and heap sizing are the two biggest costs of a
# cold Spring Boot start). That matters here specifically because two replicas share
# a 2-core sandbox: a slow-starting replica can starve its own sibling's health
# checks long enough to look like an outage that never actually happened - see
# docs/11-the-load-generator.md.
nohup java -XX:TieredStopAtLevel=1 -XX:+UseSerialGC -Xms128m -Xmx256m \
-jar "$JAR" --server.port="$PORT" --app.stage="$STAGE" \
> "$EC_LOG_DIR/instance-$PORT.log" 2>&1 < /dev/null &
echo $! > "$EC_PID_DIR/$PORT.pid"
echo "started stage $STAGE on port $PORT (pid $(cat "$EC_PID_DIR/$PORT.pid"))"
wait_healthy "$PORT"
echo "port $PORT healthy"
+14
View File
@@ -0,0 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$DIR/env.sh"
PID_FILE="$EC_PID_DIR/db-server.pid"
if [ -f "$PID_FILE" ]; then
PID="$(cat "$PID_FILE")"
if kill -0 "$PID" 2>/dev/null; then
kill -9 "$PID"
echo "stopped H2 TCP server (pid $PID)"
fi
rm -f "$PID_FILE"
fi
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
# Stops one replica by the PID file start-instance.sh wrote for it - never by matching
# the process name or command line, which risks matching the wrong process (including
# this very script's own shell). Usage: stop-instance.sh <port>
#
# Sends SIGTERM, not SIGKILL. "server.shutdown: graceful" in application.yml only
# does anything on SIGTERM: it stops accepting new connections but lets in-flight
# requests finish first. An earlier version used `kill -9` here, which bypasses that
# entirely, and requests that were in flight the instant the process vanished showed
# up in the load generator's summary as ConnectException/IOException - a real
# artifact of skipping the drain step, not a defect in the migration itself. See
# docs/13-graceful-shutdown-vs-kill-9.md.
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$DIR/env.sh"
PORT="$1"
PID_FILE="$EC_PID_DIR/$PORT.pid"
if [ -f "$PID_FILE" ]; then
PID="$(cat "$PID_FILE")"
if kill -0 "$PID" 2>/dev/null; then
# Deregister BEFORE terminating: tell the load balancer to stop sending new
# traffic here, then give its health check a couple of poll cycles to notice,
# THEN stop the process. Skipping this drain window and going straight to
# SIGTERM is what produced the ConnectException bursts in an earlier run.
curl -s -X POST "http://localhost:$PORT/admin/drain" -o /dev/null || true
sleep 1.5
kill -15 "$PID"
for i in $(seq 1 40); do
kill -0 "$PID" 2>/dev/null || break
sleep 0.25
done
if kill -0 "$PID" 2>/dev/null; then
echo "port $PORT (pid $PID) did not exit gracefully in 10s, sending SIGKILL" >&2
kill -9 "$PID"
fi
echo "stopped port $PORT (pid $PID)"
fi
rm -f "$PID_FILE"
fi
wait_down "$PORT" || echo "warning: port $PORT still answering after stop" >&2