]> begriffs open source - pg_scribe/blob - tests/test_chain_transfer.sh
Apply diffs faster with synchronous_commit = off
[pg_scribe] / tests / test_chain_transfer.sh
1 #!/usr/bin/env bash
2 #
3 # Test suite for pg_scribe chain transfer operations
4 #
5 # This test suite verifies proper behavior when transferring streaming
6 # between chains using --new-chain --start
7 #
8 # Tests cover:
9 # 1. --status correctly identifies which chain is actively streaming
10 # 2. --new-chain --start seals old chain's active.sql before transferring
11 #
12
13 set -euo pipefail
14
15 # Colors for test output
16 RED='\033[0;31m'
17 GREEN='\033[0;32m'
18 YELLOW='\033[0;33m'
19 BLUE='\033[0;34m'
20 NC='\033[0m' # No Color
21
22 # Test configuration
23 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
24 PG_SCRIBE="$SCRIPT_DIR/scripts/pg_scribe"
25 TEST_DIR="/tmp/pg_scribe_chain_transfer_test_$$"
26 TEST_DB_PREFIX="pg_scribe_chain_$$"
27 PGUSER="${PGUSER:-postgres}"
28
29 # Test counters
30 TESTS_RUN=0
31 TESTS_PASSED=0
32 TESTS_FAILED=0
33
34 # Cleanup tracking
35 DATABASES_TO_CLEANUP=()
36 SLOTS_TO_CLEANUP=()
37
38 #
39 # Logging functions
40 #
41
42 log_test() {
43     echo -e "${BLUE}TEST:${NC} $*"
44 }
45
46 log_pass() {
47     echo -e "${GREEN}PASS:${NC} $*"
48     ((TESTS_PASSED++))
49 }
50
51 log_fail() {
52     echo -e "${RED}FAIL:${NC} $*"
53     ((TESTS_FAILED++))
54 }
55
56 log_info() {
57     echo -e "${YELLOW}INFO:${NC} $*"
58 }
59
60 #
61 # Helper functions
62 #
63
64 run_psql() {
65     local dbname="$1"
66     shift
67     psql -U "$PGUSER" -d "$dbname" -tAq "$@"
68 }
69
70 query_db() {
71     local dbname="$1"
72     local query="$2"
73     run_psql "$dbname" -c "$query" 2>/dev/null || true
74 }
75
76 create_test_db() {
77     local dbname="$1"
78     log_info "Creating test database: $dbname"
79
80     # Drop if exists
81     psql -U "$PGUSER" -d postgres -c "DROP DATABASE IF EXISTS $dbname;" &>/dev/null || true
82
83     # Create database
84     psql -U "$PGUSER" -d postgres -c "CREATE DATABASE $dbname;" &>/dev/null
85
86     DATABASES_TO_CLEANUP+=("$dbname")
87 }
88
89 # shellcheck disable=SC2317  # Function called from cleanup trap handler
90 drop_test_db() {
91     local dbname="$1"
92     log_info "Dropping test database: $dbname"
93
94     # Terminate connections
95     psql -U "$PGUSER" -d postgres -c "
96         SELECT pg_terminate_backend(pid)
97         FROM pg_stat_activity
98         WHERE datname = '$dbname' AND pid <> pg_backend_pid();
99     " &>/dev/null || true
100
101     # Drop database
102     psql -U "$PGUSER" -d postgres -c "DROP DATABASE IF EXISTS $dbname;" &>/dev/null || true
103 }
104
105 create_table_with_pk() {
106     local dbname="$1"
107     local table="$2"
108     query_db "$dbname" "
109         CREATE TABLE $table (
110             id SERIAL PRIMARY KEY,
111             name TEXT,
112             created_at TIMESTAMP DEFAULT now()
113         );
114     "
115 }
116
117 # Initialize a backup directory (creates replication slot and initial backups)
118 init_backup_system() {
119     local dbname="$1"
120     local backup_dir="$2"
121     local slot="$3"
122
123     mkdir -p "$backup_dir"
124     "$PG_SCRIBE" --init -d "$dbname" -f "$backup_dir" -S "$slot" -U "$PGUSER" &>/dev/null
125
126     SLOTS_TO_CLEANUP+=("$dbname:$slot")
127 }
128
129 # Start streaming in background, return PID
130 start_streaming() {
131     local dbname="$1"
132     local backup_dir="$2"
133
134     "$PG_SCRIBE" --start -d "$dbname" -f "$backup_dir" -U "$PGUSER" &
135     local pid=$!
136
137     # Wait for streaming to start
138     sleep 2
139
140     echo "$pid"
141 }
142
143 # Stop streaming process
144 stop_streaming() {
145     local backup_dir="$1"
146
147     "$PG_SCRIBE" --stop -f "$backup_dir" &>/dev/null || true
148 }
149
150 # Get the chain ID that pg_scribe --status reports as active
151 get_status_active_chain() {
152     local dbname="$1"
153     local backup_dir="$2"
154     local slot="$3"
155
156     local status_output
157     status_output=$("$PG_SCRIBE" --status -d "$dbname" -f "$backup_dir" -S "$slot" -U "$PGUSER" 2>&1)
158
159     # Extract chain ID from line like "  chain-20251020T050048Z (ACTIVE - streaming)"
160     echo "$status_output" | grep -oP 'chain-\K[0-9TZ]+(?=.*ACTIVE.*streaming)' || echo ""
161 }
162
163 # Get the chain ID that the PID is actually writing to
164 get_actual_active_chain() {
165     local pid="$1"
166
167     # Check which active.sql file the process has open
168     local active_file
169     active_file=$(ls -l /proc/"$pid"/fd/ 2>/dev/null | grep -oP '/tmp/.*?/chain-\K[0-9TZ]+(?=/active\.sql)' || echo "")
170
171     echo "$active_file"
172 }
173
174 #
175 # Test cases
176 #
177
178 test_status_reports_correct_active_chain() {
179     ((TESTS_RUN++))
180     log_test "--status should report the chain that is actually streaming"
181
182     local dbname="${TEST_DB_PREFIX}_status"
183     local backup_dir="$TEST_DIR/status_test"
184     local slot="test_slot_status"
185
186     # Setup: Create database and init backup system
187     create_test_db "$dbname"
188     create_table_with_pk "$dbname" "users"
189     query_db "$dbname" "INSERT INTO users (name) VALUES ('Alice');"
190     init_backup_system "$dbname" "$backup_dir" "$slot"
191
192     # Start streaming to first chain
193     log_info "Starting streaming to first chain..."
194     start_streaming "$dbname" "$backup_dir" >/dev/null
195     sleep 3
196
197     # Generate some data
198     query_db "$dbname" "INSERT INTO users (name) VALUES ('Bob');"
199     sleep 2
200
201     # Create a new chain and transfer streaming to it (using --new-chain --start)
202     log_info "Creating new chain and transferring streaming..."
203     "$PG_SCRIBE" --new-chain --start -d "$dbname" -f "$backup_dir" -S "$slot" -U "$PGUSER" &>/dev/null &
204     local new_pid=$!
205     sleep 5
206
207     # Get the PID from pidfile
208     local pidfile="$backup_dir/.pg_scribe.pid"
209     local streaming_pid
210     streaming_pid=$(cat "$pidfile")
211
212     # Get actual active chain (from /proc)
213     local actual_chain
214     actual_chain=$(get_actual_active_chain "$streaming_pid")
215
216     if [[ -z "$actual_chain" ]]; then
217         log_fail "Could not determine actual active chain from /proc/$streaming_pid/fd/"
218         kill -TERM "$streaming_pid" 2>/dev/null || true
219         return 1
220     fi
221
222     log_info "Process $streaming_pid is actually writing to chain-$actual_chain"
223
224     # Get reported active chain (from --status)
225     local reported_chain
226     reported_chain=$(get_status_active_chain "$dbname" "$backup_dir" "$slot")
227
228     if [[ -z "$reported_chain" ]]; then
229         log_fail "--status did not report any active chain"
230         kill -TERM "$streaming_pid" 2>/dev/null || true
231         return 1
232     fi
233
234     log_info "--status reports chain-$reported_chain as active"
235
236     # Stop streaming
237     kill -TERM "$streaming_pid" 2>/dev/null || true
238     sleep 2
239
240     # Compare
241     if [[ "$actual_chain" != "$reported_chain" ]]; then
242         log_fail "MISMATCH: --status reports chain-$reported_chain but process is writing to chain-$actual_chain"
243         return 1
244     fi
245
246     log_pass "--status correctly reports the active chain"
247     return 0
248 }
249
250 test_new_chain_seals_old_active_sql() {
251     ((TESTS_RUN++))
252     log_test "--new-chain --start should seal old chain's active.sql"
253
254     local dbname="${TEST_DB_PREFIX}_seal"
255     local backup_dir="$TEST_DIR/seal_test"
256     local slot="test_slot_seal"
257
258     # Setup: Create database and init backup system
259     create_test_db "$dbname"
260     create_table_with_pk "$dbname" "products"
261     query_db "$dbname" "INSERT INTO products (name) VALUES ('Widget');"
262     init_backup_system "$dbname" "$backup_dir" "$slot"
263
264     # Start streaming to first chain
265     log_info "Starting streaming to first chain..."
266     start_streaming "$dbname" "$backup_dir" >/dev/null
267     sleep 3
268
269     # Generate some data to ensure active.sql has content
270     for i in {1..10}; do
271         query_db "$dbname" "INSERT INTO products (name) VALUES ('Product $i');"
272     done
273     sleep 2
274
275     # Find the old chain ID
276     local old_chain
277     old_chain=$(find "$backup_dir" -maxdepth 1 -type d -name 'chain-*' 2>/dev/null | sort | tail -1)
278     local old_chain_id
279     old_chain_id=$(basename "$old_chain" | sed 's/^chain-//')
280     log_info "Old chain: $old_chain_id"
281
282     # Verify old chain has active.sql with data
283     if [[ ! -s "$old_chain/active.sql" ]]; then
284         log_fail "Old chain's active.sql doesn't exist or is empty before transfer"
285         stop_streaming "$backup_dir"
286         return 1
287     fi
288
289     local old_active_size
290     old_active_size=$(stat -c %s "$old_chain/active.sql")
291     log_info "Old chain's active.sql size before transfer: $old_active_size bytes"
292
293     # Create a new chain and transfer streaming to it
294     log_info "Creating new chain and transferring streaming..."
295     "$PG_SCRIBE" --new-chain --start -d "$dbname" -f "$backup_dir" -S "$slot" -U "$PGUSER" &>/dev/null &
296     sleep 5
297
298     # Stop streaming
299     stop_streaming "$backup_dir"
300     sleep 2
301
302     # Check old chain: should NOT have active.sql anymore
303     if [[ -f "$old_chain/active.sql" ]]; then
304         log_fail "CRITICAL: Old chain still has active.sql after transfer!"
305         log_fail "This is the bug - active.sql should have been sealed into a timestamped differential"
306
307         # Show what's in the old chain
308         log_info "Files in old chain:"
309         ls -lh "$old_chain/"
310
311         return 1
312     fi
313
314     # Check old chain: should have a sealed differential with the content
315     local sealed_diffs
316     sealed_diffs=$(find "$old_chain" -maxdepth 1 -name 'diff-*.sql' 2>/dev/null | wc -l)
317
318     if [[ $sealed_diffs -eq 0 ]]; then
319         log_fail "Old chain has no sealed differentials after transfer"
320         log_fail "The active.sql content was lost!"
321         return 1
322     fi
323
324     log_info "Old chain has $sealed_diffs sealed differential(s)"
325
326     # Verify the sealed differential has the data that was in active.sql
327     local total_sealed_size=0
328     while IFS= read -r diff_file; do
329         local size
330         size=$(stat -c %s "$diff_file")
331         total_sealed_size=$((total_sealed_size + size))
332     done < <(find "$old_chain" -maxdepth 1 -name 'diff-*.sql' 2>/dev/null)
333
334     log_info "Total sealed differential size: $total_sealed_size bytes"
335
336     # The sealed data should be at least as much as the old active.sql
337     if [[ $total_sealed_size -lt $old_active_size ]]; then
338         log_fail "Sealed differential size ($total_sealed_size) is less than old active.sql ($old_active_size)"
339         log_fail "Data may have been lost!"
340         return 1
341     fi
342
343     log_pass "Old chain's active.sql was properly sealed into differential(s)"
344     return 0
345 }
346
347 #
348 # Cleanup
349 #
350
351 # shellcheck disable=SC2317  # Function called via trap handler
352 cleanup() {
353     log_info "Cleaning up test resources..."
354
355     # Stop any lingering streaming processes
356     if [[ -d "$TEST_DIR" ]]; then
357         find "$TEST_DIR" -name '.pg_scribe.pid' 2>/dev/null | while read -r pidfile; do
358             if [[ -f "$pidfile" ]]; then
359                 local pid
360                 pid=$(cat "$pidfile")
361                 kill -TERM "$pid" 2>/dev/null || true
362             fi
363         done
364     fi
365
366     # Give processes time to stop
367     sleep 2
368
369     # Drop replication slots
370     for entry in "${SLOTS_TO_CLEANUP[@]}"; do
371         local dbname="${entry%%:*}"
372         local slot="${entry#*:}"
373         psql -U "$PGUSER" -d "$dbname" -c "
374             SELECT pg_drop_replication_slot('$slot')
375             FROM pg_replication_slots
376             WHERE slot_name = '$slot';
377         " &>/dev/null || true
378     done
379
380     # Drop databases
381     for dbname in "${DATABASES_TO_CLEANUP[@]}"; do
382         drop_test_db "$dbname"
383     done
384
385     # Remove test directory
386     if [[ -d "$TEST_DIR" ]]; then
387         rm -rf "$TEST_DIR"
388     fi
389
390     log_info "Cleanup complete"
391 }
392
393 #
394 # Main test runner
395 #
396
397 main() {
398     echo "========================================"
399     echo "pg_scribe Chain Transfer Tests"
400     echo "========================================"
401     echo ""
402     echo "These tests verify proper behavior when"
403     echo "transferring streaming between chains"
404     echo ""
405
406     # Verify pg_scribe exists
407     if [[ ! -x "$PG_SCRIBE" ]]; then
408         echo "ERROR: pg_scribe not found or not executable: $PG_SCRIBE"
409         exit 1
410     fi
411
412     # Verify PostgreSQL is running
413     if ! psql -U "$PGUSER" -d postgres -c "SELECT 1;" &>/dev/null; then
414         echo "ERROR: Cannot connect to PostgreSQL"
415         exit 1
416     fi
417
418     # Verify wal_level is logical
419     local wal_level
420     wal_level=$(psql -U "$PGUSER" -d postgres -tAq -c "SHOW wal_level;")
421     if [[ "$wal_level" != "logical" ]]; then
422         echo "ERROR: wal_level must be 'logical', currently: $wal_level"
423         echo "Update ~/.pgenv/pgsql/data/postgresql.conf and restart PostgreSQL"
424         exit 1
425     fi
426
427     # Create test directory
428     mkdir -p "$TEST_DIR"
429
430     # Set up cleanup trap
431     trap cleanup EXIT INT TERM
432
433     echo "Running tests..."
434     echo ""
435
436     # Run all tests (use || true to prevent set -e from exiting)
437     test_status_reports_correct_active_chain || true
438     test_new_chain_seals_old_active_sql || true
439
440     # Summary
441     echo ""
442     echo "========================================"
443     echo "Test Results"
444     echo "========================================"
445     echo "Tests run:    $TESTS_RUN"
446     echo -e "Tests passed: ${GREEN}$TESTS_PASSED${NC}"
447     echo -e "Tests failed: ${RED}$TESTS_FAILED${NC}"
448     echo ""
449
450     if [[ $TESTS_FAILED -eq 0 ]]; then
451         echo -e "${GREEN}All tests passed!${NC}"
452         exit 0
453     else
454         echo -e "${RED}Some tests failed!${NC}"
455         echo ""
456         echo "This is EXPECTED before fixing the bugs."
457         echo "These failures demonstrate the chain transfer problems."
458         exit 1
459     fi
460 }
461
462 main "$@"