#!/usr/bin/env bash
#
# Reproducer: stale all-frozen visibility-map bit surviving on a standby across
# a tuple lock, after CREATE DATABASE ... STRATEGY WAL_LOG + failover.
#
# WHAT IT SHOWS
#   On the rejoined old primary (an in-recovery standby) a heap page ends up
#   with a tuple carrying a live xmax (from SELECT ... FOR UPDATE) while the VM
#   page still marks the page all-frozen.  pg_check_frozen() reports the
#   offending tuple.  This is a data-corruption hazard: VACUUM will skip the
#   page while still advancing relfrozenxid/relminmxid, risking xid/multixact
#   wraparound truncation.
#
# WHY
#   Two issues combine:
#     1. CREATE DATABASE ... STRATEGY WAL_LOG copies VM pages, but the
#        buffer-based copy logs them as standard pages, so the standby
#        reconstructs the VM from a hole-punched FPI and gets all-visible AND
#        all-frozen CLEAR, while the heap page keeps PD_ALL_VISIBLE set.  The
#        original primary keeps the real (set) VM bits via a direct memcpy.
#        This desyncs the VM between primary and standby.
#     2. After failover to the standby (new primary, VM bits clear), locking a
#        tuple (SELECT ... FOR UPDATE) writes an xmax and tries to clear the VM
#        all-frozen bit.  But that bit is ALREADY CLEAR on the new primary, so
#        the lock WAL record carries no all-frozen-clear and registers no VM
#        block.  On replay, the rejoined old primary (whose VM bit is still SET)
#        keeps its stale all-frozen bit.
#
# APPLICABILITY
#   Reproduces on PostgreSQL master and REL_19_STABLE.  On 17/18 the redo path
#   retains a fallback that clears the VM even without a registered block, so
#   the stale bit does not survive there.
#
# PREREQUISITES
#   - PostgreSQL binaries (initdb, pg_ctl, postgres, psql, pg_basebackup,
#     pg_config) on PATH, OR set PG_BINDIR to the directory containing them.
#   - The contrib extensions "pageinspect" and "pg_visibility" must be
#     installed (they ship with the standard contrib build).
#
# USAGE
#   ./repro-noop-vm-frozen-lock-portable.sh
#   PG_BINDIR=/path/to/pg/bin ./repro-noop-vm-frozen-lock-portable.sh
#
# The script creates a throwaway two-node cluster in a temporary directory,
# runs entirely on localhost using ephemeral ports, and cleans up on exit.
# Nothing outside its temp directory is modified.

set -euo pipefail

# --- Locate PostgreSQL binaries -------------------------------------------
if [ -n "${PG_BINDIR:-}" ]; then
	PATH="$PG_BINDIR:$PATH"
	export PATH
fi

for prog in initdb pg_ctl postgres psql pg_basebackup pg_config; do
	if ! command -v "$prog" >/dev/null 2>&1; then
		echo "error: '$prog' not found on PATH; set PG_BINDIR to your PostgreSQL bin dir" >&2
		exit 1
	fi
done

# Make shared libraries findable regardless of install layout / OS.
PG_LIBDIR="$(pg_config --libdir)"
case "$(uname -s)" in
	Darwin) export DYLD_LIBRARY_PATH="$PG_LIBDIR${DYLD_LIBRARY_PATH:+:$DYLD_LIBRARY_PATH}" ;;
	*)      export LD_LIBRARY_PATH="$PG_LIBDIR${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" ;;
esac

echo "Using PostgreSQL: $(postgres --version)"

# --- Configuration --------------------------------------------------------
# Pick two free localhost ports.
pick_port()
{
	python3 - <<'PY' 2>/dev/null || echo 0
import socket
s = socket.socket()
s.bind(("127.0.0.1", 0))
print(s.getsockname()[1])
s.close()
PY
}

PRIMARY_PORT="${PRIMARY_PORT:-$(pick_port)}"
STANDBY_PORT="${STANDBY_PORT:-$(pick_port)}"
if [ "$PRIMARY_PORT" = 0 ] || [ "$STANDBY_PORT" = 0 ] || [ "$PRIMARY_PORT" = "$STANDBY_PORT" ]; then
	# Fallback to fixed high ports if python3 is unavailable.
	PRIMARY_PORT=${PRIMARY_PORT:-6543}
	STANDBY_PORT=6544
fi

WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/pg-vm-frozen-repro.XXXXXX")"
REPL_USER="$(id -un)"
DB_TEMPLATE=vm_template
DB_COPY=vm_copy

cleanup()
{
	pg_ctl -D "$WORK_DIR/old_primary" stop -m immediate >/dev/null 2>&1 || true
	pg_ctl -D "$WORK_DIR/standby"     stop -m immediate >/dev/null 2>&1 || true
	rm -rf "$WORK_DIR"
}
trap cleanup EXIT

echo "Work directory: $WORK_DIR"
echo "Primary port:   $PRIMARY_PORT"
echo "Standby port:   $STANDBY_PORT"

# --- Helper: wait until a standby has replayed the primary's flushed WAL ---
wait_for_replay()
{
	local primary_port=$1
	local standby_port=$2
	local target_lsn

	target_lsn=$(psql -XAt -p "$primary_port" -d postgres \
		-c 'SELECT pg_current_wal_flush_lsn()')

	for _ in $(seq 1 300); do
		if psql -XAt -p "$standby_port" -d postgres \
			-c "SELECT pg_last_wal_replay_lsn() >= '$target_lsn'::pg_lsn" \
			| grep -qx t; then
			return
		fi
		sleep 0.1
	done

	echo "error: standby did not replay through $target_lsn" >&2
	exit 1
}

# --- Set up the initial primary -------------------------------------------
initdb -D "$WORK_DIR/old_primary" --no-sync >/dev/null
pg_ctl -D "$WORK_DIR/old_primary" -l "$WORK_DIR/old_primary.log" \
	-o "-p $PRIMARY_PORT -c listen_addresses=127.0.0.1 -c full_page_writes=off" \
	-w start >/dev/null

psql -X -v ON_ERROR_STOP=1 -p "$PRIMARY_PORT" -d postgres \
	-c "CREATE DATABASE $DB_TEMPLATE" >/dev/null
psql -X -q -v ON_ERROR_STOP=1 -p "$PRIMARY_PORT" -d "$DB_TEMPLATE" <<'SQL'
CREATE EXTENSION pageinspect;
CREATE EXTENSION pg_visibility;
CREATE TABLE t(a integer);
INSERT INTO t VALUES (1);
CREATE INDEX t_a_idx ON t(a);
SQL
# Make the page all-visible AND all-frozen in the VM.
psql -X -q -v ON_ERROR_STOP=1 -p "$PRIMARY_PORT" -d "$DB_TEMPLATE" \
	-c 'VACUUM (FREEZE) t'

# --- Base backup the standby BEFORE creating the WAL_LOG database ----------
# The primary copies the template VM bytes directly (bits set), while the
# standby reconstructs the copied VM from hole-punched FPIs and gets zero bits.
pg_basebackup -h 127.0.0.1 -p "$PRIMARY_PORT" \
	-D "$WORK_DIR/standby" -R -X stream --no-sync --checkpoint=fast >/dev/null
pg_ctl -D "$WORK_DIR/standby" -l "$WORK_DIR/standby.log" \
	-o "-p $STANDBY_PORT -c listen_addresses=127.0.0.1 -c full_page_writes=off -c hot_standby=on" \
	-w start >/dev/null

psql -X -v ON_ERROR_STOP=1 -p "$PRIMARY_PORT" -d postgres \
	-c "CREATE DATABASE $DB_COPY TEMPLATE $DB_TEMPLATE STRATEGY WAL_LOG" >/dev/null
wait_for_replay "$PRIMARY_PORT" "$STANDBY_PORT"

echo
echo "VM state after CREATE DATABASE ... STRATEGY WAL_LOG (all_frozen/all_visible):"
printf '  old primary: '
psql -XAt -F/ -p "$PRIMARY_PORT" -d "$DB_COPY" \
	-c "SELECT all_frozen, all_visible FROM pg_visibility_map('t', 0)"
printf '  standby:     '
psql -XAt -F/ -p "$STANDBY_PORT" -d "$DB_COPY" \
	-c "SELECT all_frozen, all_visible FROM pg_visibility_map('t', 0)"

# --- Fail over: promote the standby, rejoin the old primary as a standby ---
psql -X -v ON_ERROR_STOP=1 -p "$PRIMARY_PORT" -d postgres \
	-c "ALTER SYSTEM SET primary_conninfo = 'host=127.0.0.1 port=$STANDBY_PORT user=$REPL_USER'" >/dev/null

pg_ctl -D "$WORK_DIR/old_primary" stop -m fast >/dev/null
pg_ctl -D "$WORK_DIR/standby" promote >/dev/null

for _ in $(seq 1 300); do
	if psql -XAt -p "$STANDBY_PORT" -d postgres \
		-c 'SELECT NOT pg_is_in_recovery()' | grep -qx t; then
		break
	fi
	sleep 0.1
done

touch "$WORK_DIR/old_primary/standby.signal"
pg_ctl -D "$WORK_DIR/old_primary" -l "$WORK_DIR/old_primary_follow.log" \
	-o "-p $PRIMARY_PORT -c listen_addresses=127.0.0.1 -c full_page_writes=off -c hot_standby=on" \
	-w start >/dev/null

# --- The triggering tuple lock ---------------------------------------------
# The new primary has heap PD_ALL_VISIBLE set but VM all-frozen clear.  Locking
# a tuple writes an xmax; the code tries to clear all-frozen, finds it already
# clear, so the lock WAL record carries no all-frozen-clear and registers no VM
# block.  The rejoined old primary (now a standby) keeps its stale all-frozen
# bit.
psql -X -v ON_ERROR_STOP=1 -p "$STANDBY_PORT" -d "$DB_COPY" \
	-c 'SELECT a FROM t WHERE a = 1 FOR UPDATE' >/dev/null
wait_for_replay "$STANDBY_PORT" "$PRIMARY_PORT"

echo
echo "State on the rejoined old primary (still an in-recovery standby):"
psql -X -v ON_ERROR_STOP=1 -p "$PRIMARY_PORT" -d "$DB_COPY" <<'SQL'
SELECT all_frozen  AS vm_all_frozen,
       all_visible AS vm_all_visible
FROM pg_visibility_map('t', 0);

SELECT (flags & 4) <> 0 AS pd_all_visible
FROM page_header(get_raw_page('t', 0));

-- The tuple now has a live xmax (from the FOR UPDATE lock) ...
SELECT t_xmax <> 0 AS tuple_has_live_xmax
FROM heap_page_items(get_raw_page('t', 0))
WHERE lp = 1;

-- ... yet the page is marked all-frozen in the VM.  pg_check_frozen() reports
-- the corruption: a not-all-frozen tuple on an all-frozen page.
SELECT t_ctid AS corrupt_tuple_reported_by_pg_check_frozen
FROM pg_check_frozen('t');
SQL

# --- Verdict ---------------------------------------------------------------
VM_AF=$(psql -XAt -p "$PRIMARY_PORT" -d "$DB_COPY" \
	-c "SELECT all_frozen FROM pg_visibility_map('t', 0)")
NBAD=$(psql -XAt -p "$PRIMARY_PORT" -d "$DB_COPY" \
	-c "SELECT count(*) FROM pg_check_frozen('t')")

echo
if [ "$VM_AF" = t ] && [ "$NBAD" != 0 ]; then
	echo "RESULT: BUG REPRODUCED -- page is VM all-frozen yet pg_check_frozen()"
	echo "        reports $NBAD not-all-frozen tuple(s) on it."
	exit 0
else
	echo "RESULT: not reproduced on this build (vm_all_frozen=$VM_AF, bad_tuples=$NBAD)."
	echo "        Expected vm_all_frozen=t and bad_tuples>0 on affected builds."
	exit 1
fi
