修复(08-23-isolated-backup-restore-drill): 消除影子库就绪竞态

这个提交包含在:
2026-08-25 17:21:41 +08:00
父节点 18233d3433
当前提交 6d7dfbb1eb
共修改 6 个文件,包含 328 行新增和 78 行删除
@@ -44,6 +44,11 @@ TARGET_CONFIRMATION_STATUS="NOT_RUN"
RESTORE_APPLY_STATUS="NOT_RUN"
RESTORE_EXECUTION_PATH="NOT_RUN"
DUMP_LIST_EXIT_CODE="NOT_RUN"
TARGET_CONFIRMATION_EXIT_CODE="NOT_RUN"
TARGET_NAME_MATCH="NOT_RUN"
TARGET_CONFIRMATION_ATTEMPT_COUNT="0"
TARGET_CONFIRMATION_SUCCESS_STREAK="0"
TARGET_CONFIRMATION_FAILURE_REASON="NOT_APPLICABLE"
RESTORE_SCRIPT_EXIT_CODE="NOT_RUN"
PG_RESTORE_EXIT_CODE="NOT_RUN"
PG_RESTORE_ERROR_COUNT="NOT_RUN"
@@ -61,6 +66,47 @@ require_command() {
command -v "$1" >/dev/null 2>&1 || fail "required_command_missing"
}
confirm_postgres_target() {
local actual_database=""
local query_exit_code=0
local attempt=0
TARGET_CONFIRMATION_EXIT_CODE="NOT_RUN"
TARGET_NAME_MATCH="NOT_RUN"
TARGET_CONFIRMATION_ATTEMPT_COUNT="0"
TARGET_CONFIRMATION_SUCCESS_STREAK="0"
TARGET_CONFIRMATION_FAILURE_REASON="TARGET_QUERY_NONZERO"
for attempt in $(seq 1 60); do
TARGET_CONFIRMATION_ATTEMPT_COUNT="$attempt"
set +e
actual_database="$(PGPASSWORD="$postgres_password" docker exec -e PGPASSWORD \
"$CONTAINER_NAME" psql -h 127.0.0.1 -v ON_ERROR_STOP=1 \
-U "$SHADOW_DB_USER" -d "$SHADOW_DB_NAME" \
-Atqc 'SELECT current_database()' 2>>"$private_log")"
query_exit_code="$?"
set -e
TARGET_CONFIRMATION_EXIT_CODE="$query_exit_code"
if [[ "$query_exit_code" -ne 0 ]]; then
TARGET_NAME_MATCH="NOT_RUN"
TARGET_CONFIRMATION_SUCCESS_STREAK="0"
[[ "$attempt" -eq 60 ]] || sleep 1
continue
fi
if [[ "$actual_database" != "$SHADOW_DB_NAME" ]]; then
TARGET_NAME_MATCH="no"
TARGET_CONFIRMATION_FAILURE_REASON="TARGET_NAME_MISMATCH"
return 1
fi
TARGET_NAME_MATCH="yes"
TARGET_CONFIRMATION_SUCCESS_STREAK="$((TARGET_CONFIRMATION_SUCCESS_STREAK + 1))"
if [[ "$TARGET_CONFIRMATION_SUCCESS_STREAK" -ge 2 ]]; then
TARGET_CONFIRMATION_FAILURE_REASON="NONE"
return 0
fi
sleep 1
done
return 1
}
cleanup_runtime() {
local cleanup_ok=1
local container_suffix=""
@@ -126,6 +172,10 @@ emit_restore_diagnostics() {
echo "dump_list=$DUMP_LIST_STATUS"
echo "dump_list_exit_code=$DUMP_LIST_EXIT_CODE"
echo "target_confirmation=$TARGET_CONFIRMATION_STATUS"
echo "target_confirmation_exit_code=$TARGET_CONFIRMATION_EXIT_CODE"
echo "target_name_match=$TARGET_NAME_MATCH"
echo "target_confirmation_attempt_count=$TARGET_CONFIRMATION_ATTEMPT_COUNT"
echo "target_confirmation_success_streak=$TARGET_CONFIRMATION_SUCCESS_STREAK"
echo "restore_execution_path=$RESTORE_EXECUTION_PATH"
echo "restore_apply=$RESTORE_APPLY_STATUS"
echo "restore_script_exit_code=$RESTORE_SCRIPT_EXIT_CODE"
@@ -170,7 +220,7 @@ finish() {
}
trap finish EXIT HUP INT TERM
for command_name in docker python3 sha256sum tar find stat df cp openssl visudo systemctl; do
for command_name in docker python3 sha256sum tar find stat df cp openssl visudo systemctl seq; do
require_command "$command_name"
done
@@ -379,15 +429,6 @@ chmod 0600 "$env_file"
printf '%s\n' '[stage=postgres_container_start]' >>"$private_log"
docker run -d --rm --name "$CONTAINER_NAME" --network none --env-file "$env_file" \
"$POSTGRES_IMAGE" >>"$private_log" 2>&1 || fail "postgres_container_start_failed"
for _ in $(seq 1 60); do
if docker exec "$CONTAINER_NAME" pg_isready -U "$SHADOW_DB_USER" -d "$SHADOW_DB_NAME" \
>/dev/null 2>&1; then
break
fi
sleep 1
done
docker exec "$CONTAINER_NAME" pg_isready -U "$SHADOW_DB_USER" -d "$SHADOW_DB_NAME" \
>/dev/null 2>&1 || fail "postgres_not_ready"
PHASE="restore_database"
DUMP_LIST_STATUS="FAIL"
@@ -406,15 +447,8 @@ DUMP_LIST_STATUS="PASS"
TARGET_CONFIRMATION_STATUS="FAIL"
printf '%s\n' '[stage=target_confirmation]' >>"$private_log"
set +e
actual_database="$(PGPASSWORD="$postgres_password" docker exec -e PGPASSWORD \
"$CONTAINER_NAME" psql -U "$SHADOW_DB_USER" -d "$SHADOW_DB_NAME" \
-Atqc 'SELECT current_database()' 2>>"$private_log")"
target_confirmation_exit_code="$?"
set -e
if [[ "$target_confirmation_exit_code" -ne 0 \
|| "$actual_database" != "$SHADOW_DB_NAME" ]]; then
RESTORE_ERROR_CATEGORY="TARGET_CONFIRMATION_FAILED"
if ! confirm_postgres_target; then
RESTORE_ERROR_CATEGORY="$TARGET_CONFIRMATION_FAILURE_REASON"
fail "restore_target_confirmation_failed"
fi
TARGET_CONFIRMATION_STATUS="PASS"
@@ -136,12 +136,13 @@ attachment_validator_sha="$(sha256sum "$ATTACHMENT_VALIDATOR" | awk '{print $1}'
migration_fixture_names=$'20260101000000_fixture_one\n20260102000000_fixture_two'
migration_fixture_sha="$(printf '%s\n' "$migration_fixture_names" | sha256sum | awk '{print $1}')"
python3 - "$TEST_DIR/render-config.json" "$db_restore_sha" \
"$storage_restore_sha" "$attachment_validator_sha" "$migration_fixture_sha" <<'PY'
"$storage_restore_sha" "$attachment_validator_sha" "$migration_fixture_sha" \
"$POSTGRES_USER" "$POSTGRES_DB" <<'PY'
import json
import pathlib
import sys
output, db_sha, storage_sha, validator_sha, migration_sha = sys.argv[1:]
output, db_sha, storage_sha, validator_sha, migration_sha, db_user, db_name = sys.argv[1:]
payload = {
"RECEIVER_CONFIG_FILE": "/synthetic/receiver.conf",
"RESTORE_LIB_DIR": "/synthetic/restore-lib",
@@ -151,8 +152,8 @@ payload = {
"WORK_ROOT": "/tmp",
"WORK_PREFIX": "restore-contract-work-",
"CONTAINER_PREFIX": "restore-contract-container-",
"SHADOW_DB_USER": "fixture_user",
"SHADOW_DB_NAME": "fixture_restore_drill",
"SHADOW_DB_USER": db_user,
"SHADOW_DB_NAME": db_name,
"POSTGRES_IMAGE": "postgres:16-alpine",
"DEADMAN_TIMER_UNIT": "fixture-deadman.timer",
"HEALTH_TIMER_UNIT": "fixture-health.timer",
@@ -222,21 +223,18 @@ import sys
compile(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8"), sys.argv[1], "exec")
PY
if rg -n '([0-9]{1,3}[.]){3}[0-9]{1,3}' \
"$RUNNER_TEMPLATE" "$ATTACHMENT_VALIDATOR" >/dev/null; then
fail "tracked runner assets contain a site-specific value"
fi
python3 - "$0" <<'PY'
python3 - "$RUNNER_TEMPLATE" "$ATTACHMENT_VALIDATOR" "$0" <<'PY'
import ipaddress
import pathlib
import re
import sys
text = pathlib.Path(sys.argv[1]).read_text(encoding="utf-8")
for raw in re.findall(r"(?<![0-9])(?:[0-9]{1,3}\.){3}[0-9]{1,3}(?![0-9])", text):
address = ipaddress.ip_address(raw)
if not address.is_loopback:
raise SystemExit("non_loopback_address_in_contract")
for source in sys.argv[1:]:
text = pathlib.Path(source).read_text(encoding="utf-8")
for raw in re.findall(r"(?<![0-9])(?:[0-9]{1,3}\.){3}[0-9]{1,3}(?![0-9])", text):
address = ipaddress.ip_address(raw)
if not address.is_loopback:
raise SystemExit("non_loopback_address_in_tracked_runner_asset")
PY
local_account="${USER:-}"
if [[ "$local_account" != "root" && "${#local_account}" -ge 5 ]] \
@@ -272,6 +270,28 @@ if missing:
PY
pass "private raw log is initialized once and appends fixed stage markers"
python3 - "$RUNNER" "$TEST_DIR/confirm-postgres-target.sh" <<'PY'
import pathlib
import re
import sys
runner_path, output_path = map(pathlib.Path, sys.argv[1:])
text = runner_path.read_text(encoding="utf-8")
match = re.search(
r"\n(confirm_postgres_target\(\) \{.*?\n\})\n\ncleanup_runtime\(\)",
text,
re.S,
)
if not match:
raise SystemExit("confirm_postgres_target_body_missing")
output_path.write_text(match.group(1) + "\n", encoding="utf-8")
PY
if rg -n 'pg_isready' "$RUNNER" >/dev/null; then
fail "socket-only pg_isready remains in rendered runner"
fi
pass "rendered runner exposes the single TCP target-confirmation implementation"
python3 - "$RUNNER" "$TEST_DIR/validate-batch.py" <<'PY'
import pathlib
import re
@@ -423,6 +443,10 @@ if emit_position > cleanup_position:
required = {
'echo "dump_list=$DUMP_LIST_STATUS"',
'echo "target_confirmation=$TARGET_CONFIRMATION_STATUS"',
'echo "target_confirmation_exit_code=$TARGET_CONFIRMATION_EXIT_CODE"',
'echo "target_name_match=$TARGET_NAME_MATCH"',
'echo "target_confirmation_attempt_count=$TARGET_CONFIRMATION_ATTEMPT_COUNT"',
'echo "target_confirmation_success_streak=$TARGET_CONFIRMATION_SUCCESS_STREAK"',
'echo "restore_execution_path=$RESTORE_EXECUTION_PATH"',
'echo "restore_script_exit_code=$RESTORE_SCRIPT_EXIT_CODE"',
'echo "pg_restore_exit_code=$PG_RESTORE_EXIT_CODE"',
@@ -437,26 +461,145 @@ if 'RESTORE_ERROR_CATEGORY="RESTORE_APPLY_NONZERO"' not in text:
PY
pass "diagnostics precede finally-equivalent cleanup and use allowlisted fields"
cat >"$TEST_DIR/mock-target-docker" <<'MOCK_DOCKER'
#!/usr/bin/env bash
set -euo pipefail
has_tcp_loopback=0
previous=""
for argument in "$@"; do
if [[ "$previous" == "-h" && "$argument" == "127.0.0.1" ]]; then
has_tcp_loopback=1
fi
previous="$argument"
done
if [[ "$has_tcp_loopback" -ne 1 ]]; then
printf '%s\n' "$MOCK_EXPECTED_DATABASE"
exit 0
fi
index="$(cat "$MOCK_QUERY_STATE")"
index="$((index + 1))"
printf '%s\n' "$index" >"$MOCK_QUERY_STATE"
line="$(sed -n "${index}p" "$MOCK_QUERY_SEQUENCE")"
[[ -n "$line" ]] || exit 99
exit_code="${line%%|*}"
result="${line#*|}"
[[ "$exit_code" =~ ^[0-9]+$ ]] || exit 98
if [[ "$exit_code" -eq 0 ]]; then
printf '%s\n' "$result"
fi
exit "$exit_code"
MOCK_DOCKER
chmod 0700 "$TEST_DIR/mock-target-docker"
capture_target_confirmation_case() {
local label="$1"
local sequence="$2"
local output_file="$3"
local sequence_file="$TEST_DIR/$label.sequence"
local state_file="$TEST_DIR/$label.state"
printf '%s' "$sequence" >"$sequence_file"
printf '0\n' >"$state_file"
(
# shellcheck disable=SC1090
source "$TEST_DIR/confirm-postgres-target.sh"
docker() {
"$TEST_DIR/mock-target-docker" "$@"
}
sleep() {
:
}
export MOCK_EXPECTED_DATABASE="$POSTGRES_DB"
export MOCK_QUERY_SEQUENCE="$sequence_file"
export MOCK_QUERY_STATE="$state_file"
private_log="$TEST_DIR/$label.private.log"
: >"$private_log"
postgres_password="$POSTGRES_PASSWORD"
CONTAINER_NAME="fixture-container"
SHADOW_DB_USER="$POSTGRES_USER"
SHADOW_DB_NAME="$POSTGRES_DB"
if confirm_postgres_target; then
function_exit=0
else
function_exit="$?"
fi
echo "function_exit=$function_exit"
echo "target_confirmation_exit_code=$TARGET_CONFIRMATION_EXIT_CODE"
echo "target_name_match=$TARGET_NAME_MATCH"
echo "target_confirmation_attempt_count=$TARGET_CONFIRMATION_ATTEMPT_COUNT"
echo "target_confirmation_success_streak=$TARGET_CONFIRMATION_SUCCESS_STREAK"
echo "target_confirmation_failure_reason=$TARGET_CONFIRMATION_FAILURE_REASON"
) >"$output_file" 2>&1
}
capture_target_confirmation_case recovery \
"$(printf '1|\n0|%s\n0|%s\n' "$POSTGRES_DB" "$POSTGRES_DB")" \
"$TEST_DIR/readiness-recovery.out"
grep -Fxq 'function_exit=0' "$TEST_DIR/readiness-recovery.out" \
|| fail "TCP target confirmation did not recover after an initial query failure"
grep -Fxq 'target_confirmation_attempt_count=3' "$TEST_DIR/readiness-recovery.out" \
|| fail "TCP target confirmation recovery attempt count mismatch"
grep -Fxq 'target_confirmation_success_streak=2' "$TEST_DIR/readiness-recovery.out" \
|| fail "TCP target confirmation recovery streak mismatch"
pass "rendered TCP target confirmation recovers only after two consecutive successes"
capture_target_confirmation_case streak-reset \
"$(printf '0|%s\n1|\n0|%s\n0|%s\n' \
"$POSTGRES_DB" "$POSTGRES_DB" "$POSTGRES_DB")" \
"$TEST_DIR/readiness-streak-reset.out"
grep -Fxq 'function_exit=0' "$TEST_DIR/readiness-streak-reset.out" \
|| fail "TCP target confirmation did not recover after streak reset"
grep -Fxq 'target_confirmation_attempt_count=4' \
"$TEST_DIR/readiness-streak-reset.out" \
|| fail "query failure did not reset the consecutive-success streak"
pass "rendered TCP target confirmation resets its streak after a query failure"
capture_target_confirmation_case name-mismatch \
'0|wrong_restore_drill
' "$TEST_DIR/readiness-name-mismatch.out"
grep -Fxq 'function_exit=1' "$TEST_DIR/readiness-name-mismatch.out" \
|| fail "target-name mismatch did not fail"
grep -Fxq 'target_name_match=no' "$TEST_DIR/readiness-name-mismatch.out" \
|| fail "target-name mismatch diagnostic missing"
grep -Fxq 'target_confirmation_attempt_count=1' \
"$TEST_DIR/readiness-name-mismatch.out" \
|| fail "target-name mismatch was retried"
grep -Fxq 'target_confirmation_failure_reason=TARGET_NAME_MISMATCH' \
"$TEST_DIR/readiness-name-mismatch.out" \
|| fail "target-name mismatch category missing"
pass "rendered TCP target confirmation fails immediately on an exact-name mismatch"
query_failures=""
for _ in $(seq 1 60); do
query_failures+=$'1|\n'
done
capture_target_confirmation_case tcp-unavailable "$query_failures" \
"$TEST_DIR/readiness-tcp-unavailable.out"
grep -Fxq 'function_exit=1' "$TEST_DIR/readiness-tcp-unavailable.out" \
|| fail "socket-only mock passed without TCP loopback readiness"
grep -Fxq 'target_confirmation_attempt_count=60' \
"$TEST_DIR/readiness-tcp-unavailable.out" \
|| fail "permanent TCP failure did not stop at the bounded attempt count"
grep -Fxq 'target_confirmation_failure_reason=TARGET_QUERY_NONZERO' \
"$TEST_DIR/readiness-tcp-unavailable.out" \
|| fail "permanent TCP failure category missing"
pass "socket-only availability cannot bypass the rendered TCP readiness gate"
docker run -d --rm --name "$CONTAINER_NAME" -p 127.0.0.1::5432 \
-e "POSTGRES_USER=$POSTGRES_USER" \
-e "POSTGRES_PASSWORD=$POSTGRES_PASSWORD" \
-e "POSTGRES_DB=$POSTGRES_DB" \
postgres:16-alpine >/dev/null
ready_streak=0
for _ in $(seq 1 60); do
if docker exec "$CONTAINER_NAME" psql -v ON_ERROR_STOP=1 \
-U "$POSTGRES_USER" -d "$POSTGRES_DB" -Atqc 'SELECT 1' \
>/dev/null 2>&1; then
ready_streak="$((ready_streak + 1))"
[[ "$ready_streak" -ge 2 ]] && break
else
ready_streak=0
fi
sleep 1
done
[[ "$ready_streak" -ge 2 ]] \
|| fail "PostgreSQL 16 fixture did not become stably ready"
private_log="$TEST_DIR/fixture-private.log"
: >"$private_log"
postgres_password="$POSTGRES_PASSWORD"
SHADOW_DB_USER="$POSTGRES_USER"
SHADOW_DB_NAME="$POSTGRES_DB"
# shellcheck disable=SC1090
source "$TEST_DIR/confirm-postgres-target.sh"
confirm_postgres_target \
|| fail "PostgreSQL 16 fixture did not pass rendered TCP target confirmation"
pass "real PostgreSQL 16 fixture readiness uses the rendered runner implementation"
HOST_PORT="$(docker port "$CONTAINER_NAME" 5432/tcp \
| awk -F: 'NR == 1 {print $NF}')"
@@ -572,16 +715,6 @@ exec "$REAL_DOCKER" run --rm --network host -e PGPASSWORD \
HOST_RESTORE
chmod 0700 "$TEST_DIR/host-tools/psql" "$TEST_DIR/host-tools/pg_restore"
export PGPASSWORD="$POSTGRES_PASSWORD" REAL_DOCKER HOST_TOOL_MOUNT="$TEST_DIR"
for _ in $(seq 1 60); do
if "$TEST_DIR/host-tools/psql" "$HOST_DATABASE_URL" \
-Atqc 'SELECT 1' >/dev/null 2>&1; then
break
fi
sleep 1
done
"$TEST_DIR/host-tools/psql" "$HOST_DATABASE_URL" \
-Atqc 'SELECT 1' >/dev/null 2>&1 \
|| fail "host-path TCP fixture did not become ready"
docker exec "$CONTAINER_NAME" psql -v ON_ERROR_STOP=1 \
-U "$POSTGRES_USER" -d "$POSTGRES_DB" \
-c 'DROP TABLE restore_contract_marker' >/dev/null
@@ -632,6 +765,20 @@ capture_finalization_case() {
DUMP_LIST_STATUS="$dump_status"
DUMP_LIST_EXIT_CODE="$dump_exit"
TARGET_CONFIRMATION_STATUS="$target_status"
case "$target_status" in
PASS)
TARGET_CONFIRMATION_EXIT_CODE="0"
TARGET_NAME_MATCH="yes"
TARGET_CONFIRMATION_ATTEMPT_COUNT="2"
TARGET_CONFIRMATION_SUCCESS_STREAK="2"
;;
FAIL)
TARGET_CONFIRMATION_EXIT_CODE="1"
TARGET_NAME_MATCH="NOT_RUN"
TARGET_CONFIRMATION_ATTEMPT_COUNT="60"
TARGET_CONFIRMATION_SUCCESS_STREAK="0"
;;
esac
RESTORE_EXECUTION_PATH="DOCKER_STDIN"
RESTORE_APPLY_STATUS="$apply_status"
RESTORE_SCRIPT_EXIT_CODE="$script_exit"
@@ -663,9 +810,15 @@ pass "dump-list failure keeps target and apply stages NOT_RUN"
capture_finalization_case \
"$TEST_DIR/captured-target-failure" \
restore_target_confirmation_failed PASS 0 FAIL NOT_RUN NOT_RUN NOT_RUN \
NOT_RUN TARGET_CONFIRMATION_FAILED
NOT_RUN TARGET_QUERY_NONZERO
grep -Fxq 'target_confirmation=FAIL' "$TEST_DIR/captured-target-failure" \
|| fail "target-confirmation failure stage missing"
grep -Fxq 'target_confirmation_exit_code=1' \
"$TEST_DIR/captured-target-failure" \
|| fail "target-confirmation failure exit code missing"
grep -Fxq 'target_confirmation_attempt_count=60' \
"$TEST_DIR/captured-target-failure" \
|| fail "target-confirmation attempt count missing"
grep -Fxq 'restore_apply=NOT_RUN' "$TEST_DIR/captured-target-failure" \
|| fail "apply stage ran after target-confirmation failure"
pass "target-confirmation failure keeps apply stage NOT_RUN"
@@ -686,6 +839,10 @@ set +e
DUMP_LIST_STATUS="PASS"
DUMP_LIST_EXIT_CODE="0"
TARGET_CONFIRMATION_STATUS="PASS"
TARGET_CONFIRMATION_EXIT_CODE="0"
TARGET_NAME_MATCH="yes"
TARGET_CONFIRMATION_ATTEMPT_COUNT="2"
TARGET_CONFIRMATION_SUCCESS_STREAK="2"
RESTORE_EXECUTION_PATH="DOCKER_STDIN"
RESTORE_APPLY_STATUS="FAIL"
RESTORE_SCRIPT_EXIT_CODE="23"