#!/usr/bin/env bash set -euo pipefail # Re-sync one table on the single Google read-replica subscriber. # Usage: ./replicate_add_table.sh SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" # shellcheck source=read_replicate/common.sh source "${SCRIPT_DIR}/common.sh" if [[ $# -lt 1 ]]; then echo "Usage: $0 " echo "Example: $0 channels" exit 1 fi TABLE_NAME="$1" validate_public_identifier "$TABLE_NAME" "table name" DUMP_DIR="${SCRIPT_DIR}/dumps" mkdir -p "$DUMP_DIR" load_replica_target load_source PUBLICATION_NAME="$(discover_publication_name)" DEFAULT_SUBSCRIPTION_NAME="capgo_google_$(replica_region_name)" discover_subscription "$DEFAULT_SUBSCRIPTION_NAME" print_target_summary echo "==> Publication: ${PUBLICATION_NAME}" echo "==> Re-syncing table: public.${TABLE_NAME}" SAFE_CONNECTION_STRING="$(sql_literal_escape "$SOURCE_CONNECTION_STRING")" SOURCE_SLOT_STATUS=$(psql-17 "$SOURCE_DB_URL" -t -A -F '|' -c " SELECT COALESCE(wal_status, ''), COALESCE(invalidation_reason, '') FROM pg_replication_slots WHERE slot_name = '${REPLICA_SLOT_NAME}'; " || true) SOURCE_SLOT_WAL_STATUS="${SOURCE_SLOT_STATUS%%|*}" SOURCE_SLOT_INVALIDATION_REASON="${SOURCE_SLOT_STATUS#*|}" SOURCE_SLOT_LOST=false if [[ "$SOURCE_SLOT_WAL_STATUS" == "lost" || "$SOURCE_SLOT_INVALIDATION_REASON" == "wal_removed" ]]; then SOURCE_SLOT_LOST=true echo "==> Source slot is lost (${SOURCE_SLOT_WAL_STATUS}/${SOURCE_SLOT_INVALIDATION_REASON}); this run will recreate subscription and slot after copy." fi TABLE_EXISTS=$(psql-17 "$SOURCE_DB_URL" -t -A -c " SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = '${TABLE_NAME}'; " || true) if [[ -z "$TABLE_EXISTS" ]]; then echo "ERROR: public.${TABLE_NAME} does not exist in source database." exit 1 fi echo "==> Ensuring public.${TABLE_NAME} is in source publication..." psql-17 "$SOURCE_DB_URL" -v ON_ERROR_STOP=0 < Exporting source data..." psql_no_timeout "$SOURCE_DB_URL" -v ON_ERROR_STOP=1 -c "\\COPY public.${TABLE_NAME} TO STDOUT WITH (FORMAT csv, HEADER)" | gzip > "$DUMP_FILE" ROW_COUNT=$(gunzip -c "$DUMP_FILE" | wc -l | tr -d ' ') ROW_COUNT=$((ROW_COUNT - 1)) echo " Exported ${ROW_COUNT} rows." echo "==> Extracting source table schema..." psql-17 "$SOURCE_DB_URL" -t -A -c " SELECT 'CREATE TABLE public.${TABLE_NAME} (' || string_agg( quote_ident(column_name) || ' ' || CASE WHEN data_type = 'ARRAY' THEN udt_name WHEN data_type = 'USER-DEFINED' THEN udt_schema || '.' || udt_name ELSE data_type END || CASE WHEN character_maximum_length IS NOT NULL THEN '(' || character_maximum_length || ')' ELSE '' END || CASE WHEN is_nullable = 'NO' THEN ' NOT NULL' ELSE '' END || CASE WHEN column_default IS NOT NULL THEN ' DEFAULT ' || column_default ELSE '' END, ', ' ORDER BY ordinal_position ) || ');' FROM information_schema.columns WHERE table_schema = 'public' AND table_name = '${TABLE_NAME}'; " > "$SCHEMA_FILE" echo "ALTER TABLE ONLY public.${TABLE_NAME} REPLICA IDENTITY FULL;" >> "$SCHEMA_FILE" PK_COLUMNS=$(psql-17 "$SOURCE_DB_URL" -t -A -c " SELECT string_agg(quote_ident(a.attname), ', ' ORDER BY array_position(i.indkey, a.attnum)) FROM pg_index i JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) WHERE i.indrelid = 'public.${TABLE_NAME}'::regclass AND i.indisprimary; ") if [[ -n "$PK_COLUMNS" ]]; then echo "ALTER TABLE ONLY public.${TABLE_NAME} ADD CONSTRAINT ${TABLE_NAME}_pkey PRIMARY KEY (${PK_COLUMNS});" >> "$SCHEMA_FILE" fi psql-17 "$SOURCE_DB_URL" -t -A -c " SELECT pg_get_indexdef(i.indexrelid) || ';' FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid WHERE i.indrelid = 'public.${TABLE_NAME}'::regclass AND NOT i.indisprimary AND c.relname NOT LIKE '%_pkey'; " >> "$SCHEMA_FILE" echo "==> Disabling subscription while table is reloaded..." psql-17 "$REPLICA_TARGET_DB_URL" -v ON_ERROR_STOP=1 -c "ALTER SUBSCRIPTION ${REPLICA_SUBSCRIPTION_NAME} DISABLE;" cleanup_enable_subscription() { psql-17 "$REPLICA_TARGET_DB_URL" -v ON_ERROR_STOP=0 -c "ALTER SUBSCRIPTION ${REPLICA_SUBSCRIPTION_NAME} ENABLE;" >/dev/null 2>&1 || true } trap cleanup_enable_subscription EXIT TABLE_EXISTS_TARGET=$(psql-17 "$REPLICA_TARGET_DB_URL" -t -A -c " SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = '${TABLE_NAME}'; " 2>/dev/null || true) if [[ -n "$TABLE_EXISTS_TARGET" ]]; then echo "==> Truncating target public.${TABLE_NAME}..." psql-17 "$REPLICA_TARGET_DB_URL" -v ON_ERROR_STOP=1 -c "TRUNCATE TABLE public.${TABLE_NAME};" else echo "==> Creating target public.${TABLE_NAME}..." psql-17 "$REPLICA_TARGET_DB_URL" -v ON_ERROR_STOP=1 -f "$SCHEMA_FILE" fi echo "==> Importing source data into target..." gunzip -c "$DUMP_FILE" | psql_no_timeout "$REPLICA_TARGET_DB_URL" -v ON_ERROR_STOP=1 -c "\\COPY public.${TABLE_NAME} FROM STDIN WITH (FORMAT csv, HEADER)" IMPORTED_COUNT=$(psql-17 "$REPLICA_TARGET_DB_URL" -t -A -c "SELECT COUNT(*) FROM public.${TABLE_NAME};") echo " Imported ${IMPORTED_COUNT} rows." trap - EXIT if [[ "$SOURCE_SLOT_LOST" == "true" ]]; then echo "==> Recreating lost subscription and slot..." psql-17 "$REPLICA_TARGET_DB_URL" -v ON_ERROR_STOP=0 -c "ALTER SUBSCRIPTION ${REPLICA_SUBSCRIPTION_NAME} SET (slot_name = NONE);" || true psql-17 "$REPLICA_TARGET_DB_URL" -v ON_ERROR_STOP=0 -c "DROP SUBSCRIPTION ${REPLICA_SUBSCRIPTION_NAME};" || true psql-17 "$SOURCE_DB_URL" -v ON_ERROR_STOP=0 -c "SELECT pg_drop_replication_slot('${REPLICA_SLOT_NAME}') WHERE EXISTS (SELECT 1 FROM pg_replication_slots WHERE slot_name = '${REPLICA_SLOT_NAME}');" || true psql-17 "$REPLICA_TARGET_DB_URL" -v ON_ERROR_STOP=1 < Re-enabling subscription..." psql-17 "$REPLICA_TARGET_DB_URL" -v ON_ERROR_STOP=1 -c "ALTER SUBSCRIPTION ${REPLICA_SUBSCRIPTION_NAME} ENABLE;" echo "==> Refreshing subscription publication..." psql-17 "$REPLICA_TARGET_DB_URL" -v ON_ERROR_STOP=1 -c "ALTER SUBSCRIPTION ${REPLICA_SUBSCRIPTION_NAME} REFRESH PUBLICATION WITH (copy_data = false);" fi echo "==> Subscription table state:" psql-17 "$REPLICA_TARGET_DB_URL" -c " SELECT srrelid::regclass AS table_name, srsubstate FROM pg_subscription_rel WHERE srrelid = 'public.${TABLE_NAME}'::regclass; " echo "==> Done."