feat(deploy): sync schema to replication subscribers before migrating
Logical replication carries rows but not DDL, so a migration that adds a column stalls every subscriber with "missing replicated columns" until the column exists there too. _prisma_migrations is replicated, so migrate deploy on a subscriber reports nothing pending while its tables lag behind. sync-replica-schema.sh applies pending migrations to each subscriber first, tracking them locally in _replica_ddl_applied, then migrates the publisher. --check reports subscription state and columns missing on any replica. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
#!/usr/bin/env bash
|
||||
# Keep logical-replication subscribers in schema sync with the publisher.
|
||||
#
|
||||
# Logical replication ships rows, not DDL. When a migration adds a column on the
|
||||
# publisher, every subscriber keeps crashing with
|
||||
# "logical replication target relation ... is missing replicated columns"
|
||||
# and the whole subscription stalls until the column exists there too.
|
||||
#
|
||||
# _prisma_migrations is itself replicated, so `prisma migrate deploy` on a
|
||||
# subscriber reports "No pending migrations" even when its tables lag behind.
|
||||
# Applied migrations are therefore tracked per subscriber in _replica_ddl_applied.
|
||||
#
|
||||
# Usage:
|
||||
# bash deploy/sync-replica-schema.sh --init # baseline: mark current migrations as applied
|
||||
# bash deploy/sync-replica-schema.sh --dry-run # list what would run
|
||||
# bash deploy/sync-replica-schema.sh # apply to replicas, then migrate publisher
|
||||
# bash deploy/sync-replica-schema.sh --check # replication + schema drift report
|
||||
set -euo pipefail
|
||||
|
||||
API_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
MIGRATIONS_DIR="$API_DIR/prisma/migrations"
|
||||
CONFIG_FILE="${REPLICAS_ENV_FILE:-$API_DIR/deploy/replicas.env}"
|
||||
MODE="${1:-apply}"
|
||||
|
||||
case "$MODE" in
|
||||
apply|--init|--dry-run|--check) ;;
|
||||
*)
|
||||
echo "Usage: $0 [--init|--dry-run|--check]" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
command -v psql >/dev/null || { echo "psql not found" >&2; exit 1; }
|
||||
|
||||
if [[ -f "$CONFIG_FILE" ]]; then
|
||||
# shellcheck disable=SC1090
|
||||
set -a && source "$CONFIG_FILE" && set +a
|
||||
fi
|
||||
|
||||
if [[ -z "${REPLICA_DSNS:-}" ]]; then
|
||||
echo "REPLICA_DSNS is empty. Create $CONFIG_FILE from deploy/replicas.env.example" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "${DATABASE_URL:-}" && -f "$API_DIR/.env" ]]; then
|
||||
# shellcheck disable=SC1091
|
||||
set -a && source "$API_DIR/.env" && set +a
|
||||
fi
|
||||
|
||||
read -r -a REPLICAS <<< "$(tr ',\n' ' ' <<< "$REPLICA_DSNS")"
|
||||
|
||||
# psql would otherwise swallow the loop input it is called from
|
||||
run_psql() {
|
||||
psql "$@" </dev/null
|
||||
}
|
||||
|
||||
# host:port/db only — DSNs carry passwords
|
||||
label() {
|
||||
sed -E 's#^[a-z]+://([^@]*@)?##; s#\?.*$##' <<< "$1"
|
||||
}
|
||||
|
||||
# every list feeding comm is sorted with the same collation
|
||||
migration_names() {
|
||||
find "$MIGRATIONS_DIR" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | LC_ALL=C sort
|
||||
}
|
||||
|
||||
ensure_marker_table() {
|
||||
run_psql "$1" -v ON_ERROR_STOP=1 -q -c "
|
||||
CREATE TABLE IF NOT EXISTS _replica_ddl_applied (
|
||||
migration_name text PRIMARY KEY,
|
||||
applied_at timestamptz NOT NULL DEFAULT now()
|
||||
);"
|
||||
}
|
||||
|
||||
applied_names() {
|
||||
run_psql "$1" -v ON_ERROR_STOP=1 -At -c \
|
||||
"SELECT migration_name FROM _replica_ddl_applied" | LC_ALL=C sort
|
||||
}
|
||||
|
||||
pending_names() {
|
||||
comm -23 <(migration_names) <(applied_names "$1")
|
||||
}
|
||||
|
||||
apply_migration() {
|
||||
local dsn="$1" name="$2"
|
||||
local sql_file="$MIGRATIONS_DIR/$name/migration.sql"
|
||||
[[ -f "$sql_file" ]] || { echo " ! missing $sql_file" >&2; return 1; }
|
||||
|
||||
local bundle
|
||||
bundle="$(mktemp)"
|
||||
cat "$sql_file" > "$bundle"
|
||||
printf "\nINSERT INTO _replica_ddl_applied (migration_name) VALUES ('%s') ON CONFLICT DO NOTHING;\n" \
|
||||
"$name" >> "$bundle"
|
||||
|
||||
run_psql "$dsn" -v ON_ERROR_STOP=1 -q --single-transaction -f "$bundle"
|
||||
rm -f "$bundle"
|
||||
}
|
||||
|
||||
column_fingerprint() {
|
||||
run_psql "$1" -v ON_ERROR_STOP=1 -At -c "
|
||||
SELECT table_name || '.' || column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name NOT IN ('_replica_ddl_applied')" | LC_ALL=C sort
|
||||
}
|
||||
|
||||
if [[ "$MODE" == "--check" ]]; then
|
||||
for dsn in "${REPLICAS[@]}"; do
|
||||
echo "== $(label "$dsn")"
|
||||
run_psql "$dsn" -v ON_ERROR_STOP=1 -At -c "
|
||||
SELECT subname || ' received=' || COALESCE(received_lsn::text, '-')
|
||||
|| ' latest=' || COALESCE(latest_end_lsn::text, '-')
|
||||
|| ' last_msg=' || COALESCE(last_msg_receipt_time::text, '-')
|
||||
FROM pg_stat_subscription" || true
|
||||
|
||||
if [[ -n "${DATABASE_URL:-}" ]]; then
|
||||
drift="$(comm -23 <(column_fingerprint "$DATABASE_URL") <(column_fingerprint "$dsn") || true)"
|
||||
if [[ -n "$drift" ]]; then
|
||||
echo " ! columns present on publisher but missing here:"
|
||||
sed 's/^/ /' <<< "$drift"
|
||||
else
|
||||
echo " columns match publisher"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
exit 0
|
||||
fi
|
||||
|
||||
for dsn in "${REPLICAS[@]}"; do
|
||||
echo "== $(label "$dsn")"
|
||||
ensure_marker_table "$dsn"
|
||||
|
||||
if [[ "$MODE" == "--init" ]]; then
|
||||
while read -r name; do
|
||||
run_psql "$dsn" -v ON_ERROR_STOP=1 -q -c \
|
||||
"INSERT INTO _replica_ddl_applied (migration_name) VALUES ('$name') ON CONFLICT DO NOTHING"
|
||||
done < <(migration_names)
|
||||
echo " baseline recorded (no SQL executed)"
|
||||
continue
|
||||
fi
|
||||
|
||||
pending="$(pending_names "$dsn")"
|
||||
if [[ -z "$pending" ]]; then
|
||||
echo " up to date"
|
||||
continue
|
||||
fi
|
||||
|
||||
while read -r name; do
|
||||
if [[ "$MODE" == "--dry-run" ]]; then
|
||||
echo " would apply $name"
|
||||
else
|
||||
echo " applying $name"
|
||||
apply_migration "$dsn" "$name"
|
||||
fi
|
||||
done <<< "$pending"
|
||||
done
|
||||
|
||||
if [[ "$MODE" == "--dry-run" || "$MODE" == "--init" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "== publisher"
|
||||
cd "$API_DIR"
|
||||
npx prisma migrate deploy
|
||||
|
||||
echo
|
||||
echo "Replicas apply DDL first, so the publisher can stream the new columns right away."
|
||||
echo "Verify with: bash deploy/sync-replica-schema.sh --check"
|
||||
Reference in New Issue
Block a user