]> begriffs open source - pg_scribe/blob - scripts/pg_scribe
More little refactors
[pg_scribe] / scripts / pg_scribe
1 #!/usr/bin/env bash
2
3 # pg_scribe - Incremental SQL backup system for PostgreSQL
4 #
5 # This script provides a unified CLI for managing PostgreSQL backups
6 # using logical replication and plain SQL format.
7
8 set -euo pipefail
9
10 # Version
11 VERSION="0.1.0"
12
13 # Exit codes
14 EXIT_SUCCESS=0
15 EXIT_GENERAL_ERROR=1
16 EXIT_CONNECTION_ERROR=2
17 EXIT_SLOT_ERROR=3
18 EXIT_BACKUP_ERROR=4
19 EXIT_VALIDATION_ERROR=5
20 EXIT_WARNING=10
21
22 # Default values
23 DEFAULT_SLOT="pg_scribe"
24 DEFAULT_PORT="5432"
25 DEFAULT_HOST="localhost"
26 DEFAULT_STATUS_INTERVAL=10
27 DEFAULT_FSYNC_INTERVAL=10
28
29 # Global variables
30 ACTION=""
31 DBNAME=""
32 HOST="${PGHOST:-$DEFAULT_HOST}"
33 PORT="${PGPORT:-$DEFAULT_PORT}"
34 USERNAME="${PGUSER:-${USER:-}}"
35 FILE=""
36 SLOT="$DEFAULT_SLOT"
37 STATUS_INTERVAL="$DEFAULT_STATUS_INTERVAL"
38 FSYNC_INTERVAL="$DEFAULT_FSYNC_INTERVAL"
39 COMPRESS=""
40 CREATE_DB=0
41 BASE_BACKUP=""
42 NO_SYNC_SEQUENCES=0
43 NO_PASSWORD=0
44 FORCE_PASSWORD=0
45 VERBOSE=0
46 FORCE=0
47
48 # Color output support
49 if [[ "${PG_COLOR:-auto}" == "always" ]] || [[ "${PG_COLOR:-auto}" == "auto" && -t 2 ]]; then
50     RED='\033[0;31m'
51     GREEN='\033[0;32m'
52     YELLOW='\033[1;33m'
53     BLUE='\033[0;34m'
54     BOLD='\033[1m'
55     RESET='\033[0m'
56 else
57     RED=''
58     GREEN=''
59     YELLOW=''
60     BLUE=''
61     BOLD=''
62     RESET=''
63 fi
64
65 # Logging functions (output to stderr)
66 log_info() {
67     echo -e "${BLUE}INFO:${RESET} $*" >&2
68 }
69
70 log_success() {
71     echo -e "${GREEN}SUCCESS:${RESET} $*" >&2
72 }
73
74 log_warning() {
75     echo -e "${YELLOW}WARNING:${RESET} $*" >&2
76 }
77
78 log_error() {
79     echo -e "${RED}ERROR:${RESET} $*" >&2
80 }
81
82 log_step() {
83     echo -e "${BOLD}==>${RESET} $*" >&2
84 }
85
86 # Usage information
87 usage() {
88     cat <<EOF
89 pg_scribe - Incremental SQL backup system for PostgreSQL
90
91 Usage:
92   pg_scribe --init [OPTIONS]
93   pg_scribe --start [OPTIONS]
94   pg_scribe --full-backup [OPTIONS]
95   pg_scribe --restore [OPTIONS]
96   pg_scribe --status [OPTIONS]
97   pg_scribe --version
98   pg_scribe --help
99
100 Actions (exactly one required):
101   --init                Initialize backup system
102   --start               Start streaming incremental backups
103   --full-backup         Take a full backup
104   --restore             Restore from backups
105   --status              Check replication slot status
106   -V, --version         Print version and exit
107   -?, --help            Show this help and exit
108
109 Connection Options:
110   -d, --dbname=DBNAME   Database name (can be connection string)
111   -h, --host=HOSTNAME   Database server host (default: $DEFAULT_HOST)
112   -p, --port=PORT       Database server port (default: $DEFAULT_PORT)
113   -U, --username=NAME   Database user (default: \$PGUSER or \$USER)
114   -w, --no-password     Never prompt for password
115   -W, --password        Force password prompt
116
117 General Options:
118   -v, --verbose         Enable verbose mode
119
120 Options for --init:
121   -f, --file=DIRECTORY  Backup output directory (required)
122   -S, --slot=SLOTNAME   Replication slot name (default: $DEFAULT_SLOT)
123   --force               Skip validation and force initialization
124
125 Options for --start:
126   -f, --file=FILENAME   Output file (use '-' for stdout, required)
127   -S, --slot=SLOTNAME   Replication slot name (default: $DEFAULT_SLOT)
128   -s, --status-interval=SECS   Status update interval (default: $DEFAULT_STATUS_INTERVAL)
129   -F, --fsync-interval=SECS    Fsync interval (default: $DEFAULT_FSYNC_INTERVAL, 0 to disable)
130
131 Options for --full-backup:
132   -f, --file=DIRECTORY  Backup output directory (required)
133   -Z, --compress=METHOD Compression: gzip, lz4, zstd, or none (default: gzip)
134
135 Options for --restore:
136   -f, --file=DIRECTORY  Backup input directory (required)
137   -d, --dbname=DBNAME   Target database name (required)
138   -C, --create          Create target database
139   --base-backup=FILE    Specific base backup file (default: latest)
140   --no-sync-sequences   Skip sequence synchronization
141
142 Options for --status:
143   -S, --slot=SLOTNAME   Replication slot name (default: $DEFAULT_SLOT)
144   -f, --file=DIRECTORY  Backup directory to analyze (optional)
145
146 Exit Status:
147   0   Success
148   1   General error
149   2   Database connection error
150   3   Replication slot error
151   4   Backup/restore error
152   5   Invalid arguments or validation failure
153
154 Environment Variables:
155   PGHOST, PGPORT, PGDATABASE, PGUSER, PGPASSWORD, PG_COLOR
156
157 Report bugs to: https://github.com/your-repo/pg_scribe/issues
158 EOF
159 }
160
161 # Parse command line arguments
162 parse_args() {
163     if [[ $# -eq 0 ]]; then
164         usage
165         exit "$EXIT_VALIDATION_ERROR"
166     fi
167
168     while [[ $# -gt 0 ]]; do
169         case "$1" in
170             --init)
171                 [[ -n "$ACTION" ]] && { log_error "Multiple action flags specified"; exit "$EXIT_VALIDATION_ERROR"; }
172                 ACTION="init"
173                 shift
174                 ;;
175             --start)
176                 [[ -n "$ACTION" ]] && { log_error "Multiple action flags specified"; exit "$EXIT_VALIDATION_ERROR"; }
177                 ACTION="start"
178                 shift
179                 ;;
180             --full-backup)
181                 [[ -n "$ACTION" ]] && { log_error "Multiple action flags specified"; exit "$EXIT_VALIDATION_ERROR"; }
182                 ACTION="full-backup"
183                 shift
184                 ;;
185             --restore)
186                 [[ -n "$ACTION" ]] && { log_error "Multiple action flags specified"; exit "$EXIT_VALIDATION_ERROR"; }
187                 ACTION="restore"
188                 shift
189                 ;;
190             --status)
191                 [[ -n "$ACTION" ]] && { log_error "Multiple action flags specified"; exit "$EXIT_VALIDATION_ERROR"; }
192                 ACTION="status"
193                 shift
194                 ;;
195             -V|--version)
196                 echo "pg_scribe $VERSION"
197                 exit "$EXIT_SUCCESS"
198                 ;;
199             -\?|--help)
200                 usage
201                 exit "$EXIT_SUCCESS"
202                 ;;
203             -d|--dbname)
204                 DBNAME="$2"
205                 shift 2
206                 ;;
207             --dbname=*)
208                 DBNAME="${1#*=}"
209                 shift
210                 ;;
211             -h|--host)
212                 HOST="$2"
213                 shift 2
214                 ;;
215             --host=*)
216                 HOST="${1#*=}"
217                 shift
218                 ;;
219             -p|--port)
220                 PORT="$2"
221                 shift 2
222                 ;;
223             --port=*)
224                 PORT="${1#*=}"
225                 shift
226                 ;;
227             -U|--username)
228                 USERNAME="$2"
229                 shift 2
230                 ;;
231             --username=*)
232                 USERNAME="${1#*=}"
233                 shift
234                 ;;
235             -f|--file)
236                 FILE="$2"
237                 shift 2
238                 ;;
239             --file=*)
240                 FILE="${1#*=}"
241                 shift
242                 ;;
243             -S|--slot)
244                 SLOT="$2"
245                 shift 2
246                 ;;
247             --slot=*)
248                 SLOT="${1#*=}"
249                 shift
250                 ;;
251             -s|--status-interval)
252                 STATUS_INTERVAL="$2"
253                 shift 2
254                 ;;
255             --status-interval=*)
256                 STATUS_INTERVAL="${1#*=}"
257                 shift
258                 ;;
259             -F|--fsync-interval)
260                 FSYNC_INTERVAL="$2"
261                 shift 2
262                 ;;
263             --fsync-interval=*)
264                 FSYNC_INTERVAL="${1#*=}"
265                 shift
266                 ;;
267             -Z|--compress)
268                 COMPRESS="$2"
269                 shift 2
270                 ;;
271             --compress=*)
272                 COMPRESS="${1#*=}"
273                 shift
274                 ;;
275             -C|--create)
276                 CREATE_DB=1
277                 shift
278                 ;;
279             --base-backup)
280                 BASE_BACKUP="$2"
281                 shift 2
282                 ;;
283             --base-backup=*)
284                 BASE_BACKUP="${1#*=}"
285                 shift
286                 ;;
287             --no-sync-sequences)
288                 NO_SYNC_SEQUENCES=1
289                 shift
290                 ;;
291             -w|--no-password)
292                 NO_PASSWORD=1
293                 shift
294                 ;;
295             -W|--password)
296                 FORCE_PASSWORD=1
297                 shift
298                 ;;
299             -v|--verbose)
300                 VERBOSE=1
301                 shift
302                 ;;
303             --force)
304                 FORCE=1
305                 shift
306                 ;;
307             *)
308                 log_error "Unknown option: $1"
309                 usage
310                 exit "$EXIT_VALIDATION_ERROR"
311                 ;;
312         esac
313     done
314
315     # Validate action was specified
316     if [[ -z "$ACTION" ]]; then
317         log_error "No action specified"
318         usage
319         exit "$EXIT_VALIDATION_ERROR"
320     fi
321
322     # Use PGDATABASE if dbname not specified
323     if [[ -z "$DBNAME" && -n "${PGDATABASE:-}" ]]; then
324         DBNAME="$PGDATABASE"
325     fi
326 }
327
328 # Build psql connection string
329 build_psql_args() {
330     local args=()
331
332     [[ -n "$DBNAME" ]] && args+=(-d "$DBNAME")
333     [[ -n "$HOST" ]] && args+=(-h "$HOST")
334     [[ -n "$PORT" ]] && args+=(-p "$PORT")
335     [[ -n "$USERNAME" ]] && args+=(-U "$USERNAME")
336     [[ "$NO_PASSWORD" -eq 1 ]] && args+=(-w)
337     [[ "$FORCE_PASSWORD" -eq 1 ]] && args+=(-W)
338
339     printf '%s\n' "${args[@]}"
340 }
341
342 # Build pg_recvlogical connection string
343 build_pg_recvlogical_args() {
344     local args=()
345
346     [[ -n "$DBNAME" ]] && args+=(-d "$DBNAME")
347     [[ -n "$HOST" ]] && args+=(-h "$HOST")
348     [[ -n "$PORT" ]] && args+=(-p "$PORT")
349     [[ -n "$USERNAME" ]] && args+=(-U "$USERNAME")
350     [[ "$NO_PASSWORD" -eq 1 ]] && args+=(-w)
351     [[ "$FORCE_PASSWORD" -eq 1 ]] && args+=(-W)
352
353     printf '%s\n' "${args[@]}"
354 }
355
356 # Build pg_dumpall connection arguments (no -d flag)
357 build_pg_dumpall_args() {
358     local args=()
359
360     [[ -n "$HOST" ]] && args+=(-h "$HOST")
361     [[ -n "$PORT" ]] && args+=(-p "$PORT")
362     [[ -n "$USERNAME" ]] && args+=(-U "$USERNAME")
363     [[ "$NO_PASSWORD" -eq 1 ]] && args+=(-w)
364     [[ "$FORCE_PASSWORD" -eq 1 ]] && args+=(-W)
365
366     printf '%s\n' "${args[@]}"
367 }
368
369 # Generate standardized backup timestamp
370 get_backup_timestamp() {
371     date +%Y%m%d-%H%M%S
372 }
373
374 # Get human-readable file size
375 # Arguments:
376 #   $1 - file path
377 # Returns:
378 #   Echoes the file size in human-readable format (e.g., "1.2M", "5.4K")
379 get_file_size() {
380     local file_path="$1"
381     du -h "$file_path" 2>/dev/null | cut -f1
382 }
383
384 # Test database connection
385 test_connection() {
386     log_step "Testing database connection..."
387
388     local psql_args
389     mapfile -t psql_args < <(build_psql_args)
390
391     if ! psql "${psql_args[@]}" -c "SELECT version();" >/dev/null 2>&1; then
392         log_error "Failed to connect to database"
393         log_error "Connection details: host=$HOST port=$PORT dbname=$DBNAME user=$USERNAME"
394         exit "$EXIT_CONNECTION_ERROR"
395     fi
396
397     if [[ "$VERBOSE" -eq 1 ]]; then
398         log_success "Connected to database"
399     fi
400 }
401
402 # Execute SQL query and return result
403 query_db() {
404     local sql="$1"
405     local psql_args
406     mapfile -t psql_args < <(build_psql_args)
407     psql "${psql_args[@]}" -t -A -c "$sql" 2>&1
408 }
409
410 # Execute SQL query silently (return exit code only)
411 query_db_silent() {
412     local sql="$1"
413     local psql_args
414     mapfile -t psql_args < <(build_psql_args)
415     psql "${psql_args[@]}" -t -A -c "$sql" >/dev/null 2>&1
416 }
417
418 # Take a globals backup (roles, tablespaces, etc.)
419 # Arguments:
420 #   $1 - backup directory path
421 #   $2 - optional: timestamp (if not provided, generates new one)
422 # Returns:
423 #   Echoes the path to the created globals backup file
424 #   Exits script on failure
425 take_globals_backup() {
426     local backup_dir="$1"
427     local timestamp="${2:-$(get_backup_timestamp)}"
428     local globals_backup_file="$backup_dir/globals-${timestamp}.sql"
429
430     log_info "Taking globals backup: $globals_backup_file"
431
432     # Build pg_dumpall connection arguments
433     local dumpall_args
434     mapfile -t dumpall_args < <(build_pg_dumpall_args)
435
436     # Add globals-only flag and output file
437     dumpall_args+=(--globals-only)
438     dumpall_args+=(--file="$globals_backup_file")
439
440     if pg_dumpall "${dumpall_args[@]}"; then
441         local globals_size
442         globals_size=$(get_file_size "$globals_backup_file")
443         log_success "Globals backup completed: $globals_backup_file ($globals_size)"
444         echo "$globals_backup_file"
445     else
446         log_error "Globals backup failed"
447         # Clean up partial file
448         rm -f "$globals_backup_file" 2>/dev/null || true
449         exit "$EXIT_BACKUP_ERROR"
450     fi
451 }
452
453 # Validate required arguments for a command
454 # Arguments: command_name arg_name:description [arg_name:description ...]
455 # Example: validate_required_args "init" "DBNAME:database" "FILE:backup directory"
456 validate_required_args() {
457     local command_name="$1"
458     shift
459
460     local validation_failed=0
461
462     for arg_spec in "$@"; do
463         local arg_name="${arg_spec%%:*}"
464         local arg_description="${arg_spec#*:}"
465
466         # Use indirect variable reference to check if argument is set
467         if [[ -z "${!arg_name}" ]]; then
468             log_error "--${command_name} requires ${arg_description}"
469             validation_failed=1
470         fi
471     done
472
473     if [[ "$validation_failed" -eq 1 ]]; then
474         exit "$EXIT_VALIDATION_ERROR"
475     fi
476 }
477
478 # Check replication slot existence
479 # Arguments:
480 #   $1 - slot name
481 #   $2 - should_exist: 1 if slot should exist, 0 if slot should NOT exist
482 # Exits with appropriate error code if expectation is not met
483 check_replication_slot() {
484     local slot_name="$1"
485     local should_exist="$2"
486
487     local slot_exists
488     slot_exists=$(query_db "SELECT count(*) FROM pg_replication_slots WHERE slot_name = '$slot_name';")
489
490     if [[ "$should_exist" -eq 0 ]]; then
491         # Slot should NOT exist
492         if [[ "$slot_exists" -gt 0 ]]; then
493             log_error "Replication slot '$slot_name' already exists"
494             log_error ""
495             log_error "A replication slot with this name already exists in the database."
496             log_error "This may indicate:"
497             log_error "  - A previous initialization that was not cleaned up"
498             log_error "  - Another pg_scribe instance using the same slot name"
499             log_error ""
500             log_error "To resolve:"
501             log_error "  - Use a different slot name with -S/--slot option"
502             log_error "  - Or drop the existing slot (if safe):"
503             log_error "    psql -d $DBNAME -c \"SELECT pg_drop_replication_slot('$slot_name');\""
504             exit "$EXIT_SLOT_ERROR"
505         fi
506     else
507         # Slot should exist
508         if [[ "$slot_exists" -eq 0 ]]; then
509             log_error "Replication slot '$slot_name' does not exist"
510             log_error ""
511             log_error "You must initialize the backup system first:"
512             log_error "  pg_scribe --init -d $DBNAME -f <backup_dir> -S $slot_name"
513             log_error ""
514             log_error "Or verify the slot name is correct with:"
515             log_error "  psql -d $DBNAME -c \"SELECT slot_name FROM pg_replication_slots;\""
516             exit "$EXIT_SLOT_ERROR"
517         fi
518         log_success "Replication slot '$slot_name' found"
519     fi
520 }
521
522 #
523 # --init command implementation
524 #
525 cmd_init() {
526     log_step "Initializing pg_scribe backup system"
527
528     # Validate required arguments
529     validate_required_args "init" "DBNAME:-d/--dbname" "FILE:-f/--file (backup directory)"
530
531     # Cleanup tracking for failure handling
532     local CREATED_SLOT=""
533     local CREATED_FILES=()
534
535     # Cleanup function for handling failures
536     # shellcheck disable=SC2317  # Function called via trap handler
537     cleanup_on_failure() {
538         local exit_code=$?
539
540         # Only cleanup on actual failure, not on successful exit
541         if [[ $exit_code -ne 0 && $exit_code -ne $EXIT_WARNING ]]; then
542             log_info "Cleaning up after failed initialization..."
543
544             # Drop replication slot if we created it
545             if [[ -n "$CREATED_SLOT" ]]; then
546                 log_info "Dropping replication slot '$CREATED_SLOT'..."
547                 query_db "SELECT pg_drop_replication_slot('$CREATED_SLOT');" 2>/dev/null || true
548             fi
549
550             # Remove files we created
551             for file in "${CREATED_FILES[@]}"; do
552                 if [[ -f "$file" ]]; then
553                     log_info "Removing partial file: $file"
554                     rm -f "$file" 2>/dev/null || true
555                 fi
556             done
557
558             log_info "Cleanup complete"
559         fi
560     }
561
562     # Set up cleanup trap
563     trap cleanup_on_failure EXIT INT TERM
564
565     # Test connection first
566     test_connection
567
568     # Phase 1: Validation
569     log_step "Phase 1: Validation"
570
571     local validation_failed=0
572     local has_warnings=0
573
574     # Check wal_level
575     log_info "Checking wal_level configuration..."
576     local wal_level
577     wal_level=$(query_db "SHOW wal_level;")
578     if [[ "$wal_level" != "logical" ]]; then
579         log_error "CRITICAL: wal_level is '$wal_level', must be 'logical'"
580         log_error "  Fix: Add 'wal_level = logical' to postgresql.conf and restart PostgreSQL"
581         validation_failed=1
582     else
583         if [[ "$VERBOSE" -eq 1 ]]; then
584             log_success "wal_level = logical"
585         fi
586     fi
587
588     # Check max_replication_slots
589     log_info "Checking max_replication_slots configuration..."
590     local max_slots
591     max_slots=$(query_db "SHOW max_replication_slots;")
592     if [[ "$max_slots" -lt 1 ]]; then
593         log_error "CRITICAL: max_replication_slots is $max_slots, must be >= 1"
594         log_error "  Fix: Add 'max_replication_slots = 10' to postgresql.conf and restart PostgreSQL"
595         validation_failed=1
596     else
597         if [[ "$VERBOSE" -eq 1 ]]; then
598             log_success "max_replication_slots = $max_slots"
599         fi
600     fi
601
602     # Check max_wal_senders
603     log_info "Checking max_wal_senders configuration..."
604     local max_senders
605     max_senders=$(query_db "SHOW max_wal_senders;")
606     if [[ "$max_senders" -lt 1 ]]; then
607         log_error "CRITICAL: max_wal_senders is $max_senders, must be >= 1"
608         log_error "  Fix: Add 'max_wal_senders = 10' to postgresql.conf and restart PostgreSQL"
609         validation_failed=1
610     else
611         if [[ "$VERBOSE" -eq 1 ]]; then
612             log_success "max_wal_senders = $max_senders"
613         fi
614     fi
615
616     # Check replica identity on all tables
617     log_info "Checking replica identity for all tables..."
618     local bad_tables
619     bad_tables=$(query_db "
620         SELECT n.nspname || '.' || c.relname
621         FROM pg_class c
622         JOIN pg_namespace n ON n.oid = c.relnamespace
623         WHERE c.relkind = 'r'
624           AND n.nspname NOT IN ('pg_catalog', 'information_schema')
625           AND c.relreplident IN ('d', 'n')
626           AND NOT EXISTS (
627               SELECT 1 FROM pg_index i
628               WHERE i.indrelid = c.oid AND i.indisprimary
629           )
630         ORDER BY n.nspname, c.relname;
631     ")
632
633     if [[ -n "$bad_tables" ]]; then
634         log_error "CRITICAL: The following tables lack adequate replica identity:"
635         while IFS= read -r table; do
636             log_error "  - $table"
637         done <<< "$bad_tables"
638         log_error "  Fix: Add a primary key or set replica identity:"
639         log_error "    ALTER TABLE <table> ADD PRIMARY KEY (id);"
640         log_error "    -- OR --"
641         log_error "    ALTER TABLE <table> REPLICA IDENTITY FULL;"
642         validation_failed=1
643     else
644         if [[ "$VERBOSE" -eq 1 ]]; then
645             log_success "All tables have adequate replica identity"
646         fi
647     fi
648
649     # Warning: Check for unlogged tables
650     log_info "Checking for unlogged tables..."
651     local unlogged_tables
652     unlogged_tables=$(query_db "
653         SELECT n.nspname || '.' || c.relname
654         FROM pg_class c
655         JOIN pg_namespace n ON n.oid = c.relnamespace
656         WHERE c.relkind = 'r'
657           AND c.relpersistence = 'u'
658           AND n.nspname NOT IN ('pg_catalog', 'information_schema')
659         ORDER BY n.nspname, c.relname;
660     ")
661
662     if [[ -n "$unlogged_tables" ]]; then
663         log_warning "The following unlogged tables will NOT be backed up:"
664         while IFS= read -r table; do
665             log_warning "  - $table"
666         done <<< "$unlogged_tables"
667         has_warnings=1
668     fi
669
670     # Warning: Check for large objects
671     log_info "Checking for large objects..."
672     local large_object_count
673     large_object_count=$(query_db "SELECT count(*) FROM pg_largeobject_metadata;")
674
675     if [[ "$large_object_count" -gt 0 ]]; then
676         log_warning "Database contains $large_object_count large objects"
677         log_warning "Large objects are NOT incrementally backed up (only in full backups)"
678         log_warning "Consider using BYTEA columns instead for incremental backup support"
679         has_warnings=1
680     fi
681
682     # Check if validation failed
683     if [[ "$validation_failed" -eq 1 ]]; then
684         if [[ "$FORCE" -eq 1 ]]; then
685             log_warning "Validation failed but --force specified, continuing anyway..."
686         else
687             log_error "Validation failed. Fix the CRITICAL issues above and try again."
688             log_error "Or use --force to skip validation (NOT recommended)."
689             exit "$EXIT_VALIDATION_ERROR"
690         fi
691     else
692         log_success "All validation checks passed"
693     fi
694
695     # Phase 2: Setup
696     log_step "Phase 2: Setup"
697
698     # Create backup directory
699     log_info "Checking backup directory..."
700     if [[ ! -d "$FILE" ]]; then
701         if ! mkdir -p "$FILE"; then
702             log_error "Failed to create backup directory: $FILE"
703             exit "$EXIT_BACKUP_ERROR"
704         fi
705         log_success "Created backup directory: $FILE"
706     else
707         # Directory exists - check if already initialized
708         if [[ -f "$FILE/pg_scribe_metadata.txt" ]]; then
709             log_error "Backup directory already initialized: $FILE"
710             log_error "Metadata file exists: $FILE/pg_scribe_metadata.txt"
711             log_error ""
712             log_error "This directory has already been initialized with pg_scribe."
713             log_error "To take an additional full backup, use: pg_scribe --full-backup"
714             log_error ""
715             log_error "If you want to re-initialize from scratch:"
716             log_error "  1. Stop any running backup processes"
717             log_error "  2. Drop the replication slot (or verify it's safe to reuse)"
718             log_error "  3. Remove or rename the existing backup directory"
719             exit "$EXIT_VALIDATION_ERROR"
720         fi
721
722         # Directory exists but not initialized - check if empty
723         if [[ -n "$(ls -A "$FILE" 2>/dev/null)" ]]; then
724             log_error "Backup directory is not empty: $FILE"
725             log_error "The backup directory must be empty for initialization."
726             log_error "Found existing files:"
727             # shellcheck disable=SC2012  # ls used for user-friendly display, not processing
728             ls -lh "$FILE" | head -10 >&2
729             exit "$EXIT_VALIDATION_ERROR"
730         fi
731
732         log_info "Using existing empty directory: $FILE"
733     fi
734
735     # Create wal2sql extension
736     log_info "Creating wal2sql extension..."
737     if query_db_silent "CREATE EXTENSION IF NOT EXISTS wal2sql;"; then
738         log_success "wal2sql extension created (or already exists)"
739     else
740         log_error "Failed to create wal2sql extension"
741         log_error "Ensure wal2sql.so is installed in PostgreSQL's lib directory"
742         log_error "Run: cd wal2sql && make && make install"
743         exit "$EXIT_GENERAL_ERROR"
744     fi
745
746     # Create replication slot with snapshot export
747     log_info "Creating logical replication slot '$SLOT'..."
748
749     # Check if slot already exists
750     check_replication_slot "$SLOT" 0
751
752     # Create slot using SQL
753     # Note: For POC, we create the slot and take the base backup sequentially
754     # The slot will preserve WAL from its creation LSN forward, ensuring no changes are lost
755     local slot_result
756     if ! slot_result=$(query_db "SELECT slot_name, lsn FROM pg_create_logical_replication_slot('$SLOT', 'wal2sql');"); then
757         log_error "Failed to create replication slot"
758         log_error "$slot_result"
759         exit "$EXIT_SLOT_ERROR"
760     fi
761
762     CREATED_SLOT="$SLOT"  # Track for cleanup
763     log_success "Replication slot '$SLOT' created"
764
765     # Take base backup immediately after slot creation
766     # The slot preserves WAL from its creation point, so all changes will be captured
767     local timestamp
768     timestamp=$(get_backup_timestamp)
769     local base_backup_file="$FILE/base-${timestamp}.sql"
770     CREATED_FILES+=("$base_backup_file")  # Track for cleanup
771     log_info "Taking base backup: $base_backup_file"
772
773     local psql_args
774     mapfile -t psql_args < <(build_psql_args)
775     if pg_dump "${psql_args[@]}" --file="$base_backup_file"; then
776         log_success "Base backup completed: $base_backup_file"
777     else
778         log_error "Base backup failed"
779         exit "$EXIT_BACKUP_ERROR"
780     fi
781
782     # Take globals backup (using same timestamp for consistency)
783     local globals_backup_file
784     globals_backup_file=$(take_globals_backup "$FILE" "$timestamp")
785     CREATED_FILES+=("$globals_backup_file")  # Track for cleanup
786
787     # Generate metadata file
788     log_info "Generating metadata file..."
789     local metadata_file="$FILE/pg_scribe_metadata.txt"
790     CREATED_FILES+=("$metadata_file")  # Track for cleanup
791     local pg_version
792     pg_version=$(query_db "SELECT version();")
793
794     cat > "$metadata_file" <<EOF
795 pg_scribe Backup System Metadata
796 =================================
797
798 Generated: $(date -u +"%Y-%m-%d %H:%M:%S UTC")
799 pg_scribe Version: $VERSION
800
801 PostgreSQL Version:
802 $pg_version
803
804 Database: $DBNAME
805 Replication Slot: $SLOT
806
807 Extensions:
808 $(query_db "SELECT extname || ' ' || extversion FROM pg_extension ORDER BY extname;")
809
810 Encoding: $(query_db "SELECT pg_encoding_to_char(encoding) FROM pg_database WHERE datname = '$DBNAME';")
811
812 Collation: $(query_db "SELECT datcollate FROM pg_database WHERE datname = '$DBNAME';")
813 EOF
814
815     log_success "Metadata file created: $metadata_file"
816
817     # Disable cleanup trap on successful completion
818     trap - EXIT INT TERM
819
820     # Final summary
821     echo >&2
822     log_step "Initialization Complete"
823     log_success "Backup directory: $FILE"
824     log_success "Replication slot: $SLOT"
825     log_info "Next steps:"
826     log_info "  1. Start streaming incremental backups:"
827     log_info "     pg_scribe --start -d $DBNAME -f $FILE/incremental.sql -S $SLOT"
828     log_info "  2. Monitor replication slot health:"
829     log_info "     pg_scribe --status -d $DBNAME -S $SLOT"
830
831     if [[ "$has_warnings" -eq 1 ]]; then
832         exit "$EXIT_WARNING"
833     else
834         exit "$EXIT_SUCCESS"
835     fi
836 }
837
838 #
839 # --start command implementation
840 #
841 cmd_start() {
842     log_step "Starting incremental backup collection"
843
844     # Validate required arguments
845     validate_required_args "start" "DBNAME:-d/--dbname" "FILE:-f/--file (output file, or '-' for stdout)"
846
847     # Test connection
848     test_connection
849
850     # Verify replication slot exists
851     log_step "Verifying replication slot '$SLOT'..."
852     check_replication_slot "$SLOT" 1
853
854     # Build pg_recvlogical arguments
855     local pg_recv_args=()
856     mapfile -t pg_recv_args < <(build_pg_recvlogical_args)
857
858     # Add required arguments
859     pg_recv_args+=(--slot="$SLOT")
860     pg_recv_args+=(--start)
861     pg_recv_args+=(--file="$FILE")
862
863     # Add plugin options
864     pg_recv_args+=(--option=include_transaction=on)
865
866     # Add status interval
867     pg_recv_args+=(--status-interval="$STATUS_INTERVAL")
868
869     # Add fsync interval (0 means disabled)
870     if [[ "$FSYNC_INTERVAL" -gt 0 ]]; then
871         pg_recv_args+=(--fsync-interval="$FSYNC_INTERVAL")
872     else
873         # For fsync-interval=0, we skip the parameter to avoid pg_recvlogical errors
874         log_info "Fsync disabled (fsync-interval=0)"
875     fi
876
877     # Display configuration
878     log_step "Configuration"
879     log_info "Database: $DBNAME"
880     log_info "Replication slot: $SLOT"
881     log_info "Output file: $FILE"
882     log_info "Status interval: ${STATUS_INTERVAL}s"
883     if [[ "$FSYNC_INTERVAL" -gt 0 ]]; then
884         log_info "Fsync interval: ${FSYNC_INTERVAL}s"
885     else
886         log_info "Fsync: disabled"
887     fi
888     echo >&2
889
890     # Start streaming - replace this process with pg_recvlogical
891     log_step "Starting streaming replication..."
892     log_info "Press Ctrl+C to stop"
893     log_info "Send SIGHUP to rotate output file"
894     echo >&2
895
896     # Replace this process with pg_recvlogical
897     # This eliminates signal forwarding issues and prevents orphaned processes
898     # The PID stays the same, making cleanup in tests more reliable
899     exec pg_recvlogical "${pg_recv_args[@]}"
900 }
901
902 #
903 # --full-backup command implementation
904 #
905 cmd_full_backup() {
906     log_step "Taking full backup"
907
908     # Validate required arguments
909     validate_required_args "full-backup" "DBNAME:-d/--dbname" "FILE:-f/--file (backup directory)"
910
911     # Test connection
912     test_connection
913
914     # Ensure backup directory exists
915     if [[ ! -d "$FILE" ]]; then
916         log_error "Backup directory does not exist: $FILE"
917         log_error "Create the directory first or run --init to initialize the backup system"
918         exit "$EXIT_BACKUP_ERROR"
919     fi
920
921     # Set compression method (default: gzip)
922     local compress_method="${COMPRESS:-gzip}"
923     if [[ "$compress_method" == "none" ]]; then
924         compress_method=""
925     fi
926
927     # Generate timestamped filenames
928     local timestamp
929     timestamp=$(get_backup_timestamp)
930     local base_backup_file="$FILE/base-${timestamp}.sql"
931
932     # Add compression extension to base backup if applicable
933     # Note: We don't compress globals since it's typically very small (< 1KB)
934     if [[ -n "$compress_method" ]]; then
935         # Extract compression type (before colon)
936         local compress_type="${compress_method%%:*}"
937         case "$compress_type" in
938             gzip)
939                 base_backup_file="${base_backup_file}.gz"
940                 ;;
941             lz4)
942                 base_backup_file="${base_backup_file}.lz4"
943                 ;;
944             zstd)
945                 base_backup_file="${base_backup_file}.zst"
946                 ;;
947             *)
948                 log_error "Unknown compression method: $compress_type"
949                 log_error "Supported methods: gzip, lz4, zstd, none"
950                 exit "$EXIT_VALIDATION_ERROR"
951                 ;;
952         esac
953     fi
954
955     # Take base backup
956     log_info "Taking base backup: $base_backup_file"
957     if [[ -n "$compress_method" ]]; then
958         log_info "Compression: $compress_method"
959     fi
960
961     local psql_args
962     mapfile -t psql_args < <(build_psql_args)
963
964     # Build pg_dump command
965     local pg_dump_args=("${psql_args[@]}")
966     if [[ -n "$compress_method" ]]; then
967         pg_dump_args+=(--compress="$compress_method")
968     fi
969     pg_dump_args+=(--file="$base_backup_file")
970
971     if pg_dump "${pg_dump_args[@]}"; then
972         local backup_size
973         backup_size=$(get_file_size "$base_backup_file")
974         log_success "Base backup completed: $base_backup_file ($backup_size)"
975     else
976         log_error "Base backup failed"
977         # Clean up partial file
978         rm -f "$base_backup_file" 2>/dev/null || true
979         exit "$EXIT_BACKUP_ERROR"
980     fi
981
982     # Take globals backup (uncompressed - typically < 1KB, not worth compressing)
983     local globals_backup_file
984     globals_backup_file=$(take_globals_backup "$FILE" "$timestamp")
985
986     # Generate/update metadata file
987     log_info "Updating metadata file..."
988     local metadata_file="$FILE/pg_scribe_metadata.txt"
989     local pg_version
990     pg_version=$(query_db "SELECT version();")
991
992     cat > "$metadata_file" <<EOF
993 pg_scribe Backup System Metadata
994 =================================
995
996 Last Updated: $(date -u +"%Y-%m-%d %H:%M:%S UTC")
997 pg_scribe Version: $VERSION
998
999 PostgreSQL Version:
1000 $pg_version
1001
1002 Database: $DBNAME
1003
1004 Latest Full Backup:
1005   Base: $(basename "$base_backup_file")
1006   Globals: $(basename "$globals_backup_file")
1007   Timestamp: $timestamp
1008
1009 Extensions:
1010 $(query_db "SELECT extname || ' ' || extversion FROM pg_extension ORDER BY extname;")
1011
1012 Encoding: $(query_db "SELECT pg_encoding_to_char(encoding) FROM pg_database WHERE datname = '$DBNAME';")
1013
1014 Collation: $(query_db "SELECT datcollate FROM pg_database WHERE datname = '$DBNAME';")
1015 EOF
1016
1017     log_success "Metadata file updated: $metadata_file"
1018
1019     # Final summary
1020     echo >&2
1021     log_step "Full Backup Complete"
1022     log_success "Base backup: $base_backup_file"
1023     log_success "Globals backup: $globals_backup_file"
1024     log_success "Backup directory: $FILE"
1025
1026     exit "$EXIT_SUCCESS"
1027 }
1028
1029 #
1030 # --restore command implementation
1031 #
1032 cmd_restore() {
1033     log_step "Restoring database from backup"
1034
1035     # Validate required arguments
1036     validate_required_args "restore" "DBNAME:-d/--dbname (target database)" "FILE:-f/--file (backup directory)"
1037
1038     # Verify backup directory exists
1039     if [[ ! -d "$FILE" ]]; then
1040         log_error "Backup directory does not exist: $FILE"
1041         exit "$EXIT_BACKUP_ERROR"
1042     fi
1043
1044     # Find base backup
1045     log_step "Locating backups"
1046     local base_backup_path=""
1047
1048     if [[ -n "$BASE_BACKUP" ]]; then
1049         # Use specified base backup
1050         if [[ ! -f "$BASE_BACKUP" ]]; then
1051             log_error "Specified base backup not found: $BASE_BACKUP"
1052             exit "$EXIT_BACKUP_ERROR"
1053         fi
1054         base_backup_path="$BASE_BACKUP"
1055         log_info "Using specified base backup: $(basename "$base_backup_path")"
1056     else
1057         # Find latest base backup (uncompressed or compressed)
1058         local latest_base
1059         latest_base=$(find "$FILE" -maxdepth 1 \( -name 'base-*.sql' -o -name 'base-*.sql.gz' -o -name 'base-*.sql.zst' -o -name 'base-*.sql.lz4' \) -printf '%T@ %p\n' 2>/dev/null | sort -rn | head -1 | cut -d' ' -f2-)
1060
1061         if [[ -z "$latest_base" ]]; then
1062             log_error "No base backup found in directory: $FILE"
1063             log_error "Run --init or --full-backup first to create a base backup"
1064             exit "$EXIT_BACKUP_ERROR"
1065         fi
1066
1067         base_backup_path="$latest_base"
1068         log_info "Found base backup: $(basename "$base_backup_path")"
1069     fi
1070
1071     # Extract timestamp from base backup filename for finding matching globals
1072     local base_timestamp
1073     base_timestamp=$(basename "$base_backup_path" | sed -E 's/base-([0-9]{8}-[0-9]{6}).*/\1/')
1074
1075     # Find matching globals backup
1076     local globals_backup_path
1077     globals_backup_path=$(find "$FILE" -maxdepth 1 -name "globals-${base_timestamp}.sql" 2>/dev/null | head -1)
1078
1079     if [[ -z "$globals_backup_path" ]]; then
1080         # Try to find any globals backup as fallback
1081         globals_backup_path=$(find "$FILE" -maxdepth 1 -name 'globals-*.sql' -printf '%T@ %p\n' 2>/dev/null | sort -rn | head -1 | cut -d' ' -f2-)
1082
1083         if [[ -n "$globals_backup_path" ]]; then
1084             log_warning "Exact matching globals backup not found, using: $(basename "$globals_backup_path")"
1085         else
1086             log_warning "No globals backup found (roles and tablespaces will not be restored)"
1087         fi
1088     else
1089         log_info "Found globals backup: $(basename "$globals_backup_path")"
1090     fi
1091
1092     # Find incremental backups (if any)
1093     local incremental_files=()
1094     mapfile -t incremental_files < <(find "$FILE" -maxdepth 1 -name '*.sql' ! -name 'base-*.sql' ! -name 'globals-*.sql' -printf '%T@ %p\n' 2>/dev/null | sort -n | cut -d' ' -f2-)
1095
1096     if [[ ${#incremental_files[@]} -gt 0 ]]; then
1097         log_info "Found ${#incremental_files[@]} incremental backup file(s)"
1098     else
1099         log_info "No incremental backup files found (will restore base backup only)"
1100     fi
1101
1102     # Create target database if requested
1103     if [[ "$CREATE_DB" -eq 1 ]]; then
1104         log_step "Creating target database"
1105
1106         # Connect to postgres database (not target database) to create it
1107         local create_dbname="$DBNAME"
1108         DBNAME="postgres"
1109
1110         # Test connection to postgres database
1111         test_connection
1112
1113         # Check if database already exists
1114         local db_exists
1115         db_exists=$(query_db "SELECT count(*) FROM pg_database WHERE datname = '$create_dbname';")
1116
1117         if [[ "$db_exists" -gt 0 ]]; then
1118             log_error "Database '$create_dbname' already exists"
1119             log_error "Drop it first or omit --create flag to restore into existing database"
1120             exit "$EXIT_BACKUP_ERROR"
1121         fi
1122
1123         # Create database
1124         if query_db_silent "CREATE DATABASE \"$create_dbname\";"; then
1125             log_success "Created database: $create_dbname"
1126         else
1127             log_error "Failed to create database: $create_dbname"
1128             exit "$EXIT_BACKUP_ERROR"
1129         fi
1130
1131         # Switch back to target database for subsequent operations
1132         DBNAME="$create_dbname"
1133     fi
1134
1135     # Test connection to target database
1136     test_connection
1137
1138     # Restore globals backup
1139     if [[ -n "$globals_backup_path" ]]; then
1140         log_step "Restoring globals (roles, tablespaces)"
1141
1142         # Build connection args for psql
1143         # Note: globals must be restored to postgres database, not target database
1144         local save_dbname="$DBNAME"
1145         DBNAME="postgres"
1146         local psql_args
1147         mapfile -t psql_args < <(build_psql_args)
1148         DBNAME="$save_dbname"
1149
1150         if psql "${psql_args[@]}" -f "$globals_backup_path" >/dev/null 2>&1; then
1151             log_success "Globals restored successfully"
1152         else
1153             log_warning "Globals restore had errors (may be expected if roles already exist)"
1154         fi
1155     fi
1156
1157     # Restore base backup
1158     log_step "Restoring base backup"
1159     local start_time
1160     start_time=$(date +%s)
1161
1162     local psql_args
1163     mapfile -t psql_args < <(build_psql_args)
1164
1165     # Handle compressed backups
1166     if [[ "$base_backup_path" == *.gz ]]; then
1167         log_info "Decompressing gzip backup..."
1168         if gunzip -c "$base_backup_path" | psql "${psql_args[@]}" >/dev/null 2>&1; then
1169             log_success "Base backup restored successfully"
1170         else
1171             log_error "Base backup restore failed"
1172             exit "$EXIT_BACKUP_ERROR"
1173         fi
1174     elif [[ "$base_backup_path" == *.zst ]]; then
1175         log_info "Decompressing zstd backup..."
1176         if zstd -dc "$base_backup_path" | psql "${psql_args[@]}" >/dev/null 2>&1; then
1177             log_success "Base backup restored successfully"
1178         else
1179             log_error "Base backup restore failed"
1180             exit "$EXIT_BACKUP_ERROR"
1181         fi
1182     elif [[ "$base_backup_path" == *.lz4 ]]; then
1183         log_info "Decompressing lz4 backup..."
1184         if lz4 -dc "$base_backup_path" | psql "${psql_args[@]}" >/dev/null 2>&1; then
1185             log_success "Base backup restored successfully"
1186         else
1187             log_error "Base backup restore failed"
1188             exit "$EXIT_BACKUP_ERROR"
1189         fi
1190     else
1191         # Uncompressed backup
1192         if psql "${psql_args[@]}" -f "$base_backup_path" >/dev/null 2>&1; then
1193             log_success "Base backup restored successfully"
1194         else
1195             log_error "Base backup restore failed"
1196             exit "$EXIT_BACKUP_ERROR"
1197         fi
1198     fi
1199
1200     # Apply incremental backups
1201     if [[ ${#incremental_files[@]} -gt 0 ]]; then
1202         log_step "Applying incremental backups"
1203
1204         for inc_file in "${incremental_files[@]}"; do
1205             log_info "Applying: $(basename "$inc_file")"
1206
1207             if psql "${psql_args[@]}" -f "$inc_file" >/dev/null 2>&1; then
1208                 if [[ "$VERBOSE" -eq 1 ]]; then
1209                     log_success "Applied: $(basename "$inc_file")"
1210                 fi
1211             else
1212                 log_error "Failed to apply incremental backup: $(basename "$inc_file")"
1213                 log_error "Restore is incomplete"
1214                 exit "$EXIT_BACKUP_ERROR"
1215             fi
1216         done
1217
1218         log_success "All incremental backups applied successfully"
1219     fi
1220
1221     # Synchronize sequences
1222     if [[ "$NO_SYNC_SEQUENCES" -eq 0 ]]; then
1223         log_step "Synchronizing sequences"
1224
1225         # Query all sequences and their associated tables
1226         local seq_sync_sql
1227         seq_sync_sql=$(query_db "
1228             SELECT
1229                 'SELECT setval(' ||
1230                 quote_literal(sn.nspname || '.' || s.relname) ||
1231                 ', GREATEST((SELECT COALESCE(MAX(' ||
1232                 quote_ident(a.attname) ||
1233                 '), 1) FROM ' ||
1234                 quote_ident(tn.nspname) || '.' || quote_ident(t.relname) ||
1235                 '), 1));'
1236             FROM pg_class s
1237             JOIN pg_namespace sn ON sn.oid = s.relnamespace
1238             JOIN pg_depend d ON d.objid = s.oid AND d.deptype = 'a'
1239             JOIN pg_class t ON t.oid = d.refobjid
1240             JOIN pg_namespace tn ON tn.oid = t.relnamespace
1241             JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = d.refobjsubid
1242             WHERE s.relkind = 'S'
1243               AND sn.nspname NOT IN ('pg_catalog', 'information_schema')
1244             ORDER BY sn.nspname, s.relname;
1245         " 2>/dev/null)
1246
1247         if [[ -n "$seq_sync_sql" ]]; then
1248             local seq_count=0
1249             while IFS= read -r sync_cmd; do
1250                 if query_db_silent "$sync_cmd"; then
1251                     seq_count=$((seq_count + 1))
1252                     if [[ "$VERBOSE" -eq 1 ]]; then
1253                         log_info "Synced sequence: $(echo "$sync_cmd" | grep -oP "'\K[^']+(?=')")"
1254                     fi
1255                 else
1256                     log_warning "Failed to sync sequence: $sync_cmd"
1257                 fi
1258             done <<< "$seq_sync_sql"
1259
1260             log_success "Synchronized $seq_count sequence(s)"
1261         else
1262             log_info "No sequences found to synchronize"
1263         fi
1264     else
1265         log_info "Skipping sequence synchronization (--no-sync-sequences specified)"
1266     fi
1267
1268     # Calculate restore duration
1269     local end_time
1270     end_time=$(date +%s)
1271     local duration=$((end_time - start_time))
1272
1273     # Report statistics
1274     log_step "Restore Statistics"
1275
1276     # Count rows in all tables
1277     log_info "Counting rows in restored tables..."
1278     local table_count
1279     table_count=$(query_db "SELECT count(*) FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.relkind = 'r' AND n.nspname NOT IN ('pg_catalog', 'information_schema');" 2>/dev/null)
1280
1281     local total_rows
1282     total_rows=$(query_db "
1283         SELECT COALESCE(SUM(n_live_tup), 0)
1284         FROM pg_stat_user_tables;
1285     " 2>/dev/null)
1286
1287     echo -e "${BOLD}Database:${RESET}         $DBNAME" >&2
1288     echo -e "${BOLD}Tables Restored:${RESET}  $table_count" >&2
1289     echo -e "${BOLD}Total Rows:${RESET}       $total_rows (approximate)" >&2
1290     echo -e "${BOLD}Duration:${RESET}         ${duration}s" >&2
1291     echo -e "${BOLD}Base Backup:${RESET}      $(basename "$base_backup_path")" >&2
1292
1293     if [[ ${#incremental_files[@]} -gt 0 ]]; then
1294         echo -e "${BOLD}Incremental Files:${RESET} ${#incremental_files[@]}" >&2
1295     fi
1296
1297     # Final success message
1298     echo >&2
1299     log_step "Restore Complete"
1300     log_success "Database successfully restored to: $DBNAME"
1301     log_info "Next steps:"
1302     log_info "  1. Verify data integrity:"
1303     log_info "     psql -d $DBNAME -c 'SELECT COUNT(*) FROM <your_table>;'"
1304     log_info "  2. Run application smoke tests"
1305     log_info "  3. Switch application to restored database"
1306
1307     exit "$EXIT_SUCCESS"
1308 }
1309
1310 #
1311 # --status command implementation
1312 #
1313 cmd_status() {
1314     log_step "Checking pg_scribe backup system status"
1315
1316     # Validate required arguments
1317     validate_required_args "status" "DBNAME:-d/--dbname"
1318
1319     # Test connection
1320     test_connection
1321
1322     # Track warnings for exit code
1323     local has_warnings=0
1324
1325     # Check replication slot status
1326     log_step "Replication Slot Status"
1327
1328     # Verify replication slot exists
1329     check_replication_slot "$SLOT" 1
1330
1331     # Query slot details
1332     local slot_info
1333     slot_info=$(query_db "
1334         SELECT
1335             slot_name,
1336             slot_type,
1337             database,
1338             active,
1339             restart_lsn,
1340             confirmed_flush_lsn,
1341             pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn) as restart_lag_bytes,
1342             pg_wal_lsn_diff(pg_current_wal_lsn(), confirmed_flush_lsn) as confirmed_lag_bytes,
1343             pg_current_wal_lsn() as current_lsn
1344         FROM pg_replication_slots
1345         WHERE slot_name = '$SLOT';
1346     " | head -1)
1347
1348     # Parse slot info
1349     IFS='|' read -r slot_name slot_type db_name active restart_lsn confirmed_flush_lsn restart_lag_bytes confirmed_lag_bytes current_lsn <<< "$slot_info"
1350
1351     # Display slot information
1352     echo -e "${BOLD}Slot Name:${RESET}       $slot_name" >&2
1353     echo -e "${BOLD}Slot Type:${RESET}       $slot_type" >&2
1354     echo -e "${BOLD}Database:${RESET}        $db_name" >&2
1355
1356     if [[ "$active" == "t" ]]; then
1357         echo -e "${BOLD}Active:${RESET}          ${GREEN}Yes${RESET}" >&2
1358     else
1359         echo -e "${BOLD}Active:${RESET}          ${YELLOW}No${RESET}" >&2
1360         log_warning "Replication slot is not active"
1361         has_warnings=1
1362     fi
1363
1364     echo -e "${BOLD}Current WAL LSN:${RESET} $current_lsn" >&2
1365     echo -e "${BOLD}Restart LSN:${RESET}     $restart_lsn" >&2
1366     echo -e "${BOLD}Confirmed LSN:${RESET}   $confirmed_flush_lsn" >&2
1367
1368     # Format lag in human-readable sizes
1369     local restart_lag_mb=$((restart_lag_bytes / 1024 / 1024))
1370     local confirmed_lag_mb=$((confirmed_lag_bytes / 1024 / 1024))
1371
1372     # Check lag thresholds (based on design doc)
1373     if [[ "$restart_lag_bytes" -gt 10737418240 ]]; then
1374         # > 10GB - CRITICAL
1375         echo -e "${BOLD}Restart Lag:${RESET}     ${RED}${restart_lag_mb} MB (CRITICAL!)${RESET}" >&2
1376         log_error "CRITICAL: Replication lag exceeds 10GB!"
1377         log_error "  This may cause disk space issues or database shutdown"
1378         log_error "  Consider dropping the slot if backup collection has stopped"
1379         has_warnings=1
1380     elif [[ "$restart_lag_bytes" -gt 1073741824 ]]; then
1381         # > 1GB - WARNING
1382         echo -e "${BOLD}Restart Lag:${RESET}     ${YELLOW}${restart_lag_mb} MB (WARNING)${RESET}" >&2
1383         log_warning "Replication lag exceeds 1GB"
1384         log_warning "  Ensure backup collection is running and healthy"
1385         has_warnings=1
1386     else
1387         echo -e "${BOLD}Restart Lag:${RESET}     ${GREEN}${restart_lag_mb} MB${RESET}" >&2
1388     fi
1389
1390     if [[ "$confirmed_lag_bytes" -gt 10737418240 ]]; then
1391         echo -e "${BOLD}Confirmed Lag:${RESET}   ${RED}${confirmed_lag_mb} MB (CRITICAL!)${RESET}" >&2
1392         has_warnings=1
1393     elif [[ "$confirmed_lag_bytes" -gt 1073741824 ]]; then
1394         echo -e "${BOLD}Confirmed Lag:${RESET}   ${YELLOW}${confirmed_lag_mb} MB (WARNING)${RESET}" >&2
1395         has_warnings=1
1396     else
1397         echo -e "${BOLD}Confirmed Lag:${RESET}   ${GREEN}${confirmed_lag_mb} MB${RESET}" >&2
1398     fi
1399
1400     # Check slot age (if we can determine it)
1401     # Note: pg_replication_slots doesn't directly track creation time, but we can estimate from WAL
1402     echo >&2
1403
1404     # Analyze backup directory if provided
1405     if [[ -n "$FILE" ]]; then
1406         log_step "Backup Directory Analysis"
1407
1408         if [[ ! -d "$FILE" ]]; then
1409             log_warning "Backup directory does not exist: $FILE"
1410             has_warnings=1
1411         else
1412             # Count base backups
1413             local base_count
1414             base_count=$(find "$FILE" -maxdepth 1 -name 'base-*.sql' 2>/dev/null | wc -l)
1415             echo -e "${BOLD}Base Backups:${RESET}    $base_count" >&2
1416
1417             if [[ "$base_count" -gt 0 ]]; then
1418                 # Show latest base backup
1419                 local latest_base
1420                 latest_base=$(find "$FILE" -maxdepth 1 -name 'base-*.sql' -printf '%T@ %p\n' 2>/dev/null | sort -rn | head -1 | cut -d' ' -f2-)
1421                 local base_name
1422                 base_name=$(basename "$latest_base")
1423                 local base_date
1424                 base_date=$(stat -c %y "$latest_base" 2>/dev/null | cut -d. -f1)
1425                 echo -e "${BOLD}Latest Base:${RESET}     $base_name ($base_date)" >&2
1426
1427                 # Show base backup size
1428                 local base_size
1429                 base_size=$(get_file_size "$latest_base")
1430                 echo -e "${BOLD}Base Size:${RESET}       $base_size" >&2
1431             else
1432                 log_warning "No base backups found in directory"
1433                 has_warnings=1
1434             fi
1435
1436             # Count globals backups
1437             local globals_count
1438             globals_count=$(find "$FILE" -maxdepth 1 -name 'globals-*.sql' 2>/dev/null | wc -l)
1439             echo -e "${BOLD}Globals Backups:${RESET} $globals_count" >&2
1440
1441             # Check for incremental backup files
1442             # Note: Incremental files are created by --start command
1443             # They may have various names depending on configuration
1444             local incremental_count
1445             incremental_count=$(find "$FILE" -maxdepth 1 -name '*.sql' ! -name 'base-*.sql' ! -name 'globals-*.sql' 2>/dev/null | wc -l)
1446
1447             if [[ "$incremental_count" -gt 0 ]]; then
1448                 echo -e "${BOLD}Incremental Files:${RESET} $incremental_count" >&2
1449
1450                 # Calculate total size of incremental files
1451                 local incremental_size
1452                 incremental_size=$(find "$FILE" -maxdepth 1 -name '*.sql' ! -name 'base-*.sql' ! -name 'globals-*.sql' -exec du -ch {} + 2>/dev/null | grep total | cut -f1)
1453                 echo -e "${BOLD}Incremental Size:${RESET} $incremental_size" >&2
1454
1455                 # Show most recent incremental file
1456                 local latest_incremental
1457                 latest_incremental=$(find "$FILE" -maxdepth 1 -name '*.sql' ! -name 'base-*.sql' ! -name 'globals-*.sql' -printf '%T@ %p\n' 2>/dev/null | sort -rn | head -1 | cut -d' ' -f2-)
1458                 if [[ -n "$latest_incremental" ]]; then
1459                     local inc_name
1460                     inc_name=$(basename "$latest_incremental")
1461                     local inc_date
1462                     inc_date=$(stat -c %y "$latest_incremental" 2>/dev/null | cut -d. -f1)
1463                     local inc_age_seconds
1464                     inc_age_seconds=$(( $(date +%s) - $(stat -c %Y "$latest_incremental" 2>/dev/null) ))
1465                     local inc_age_minutes=$((inc_age_seconds / 60))
1466
1467                     echo -e "${BOLD}Latest Incremental:${RESET} $inc_name ($inc_date)" >&2
1468
1469                     # Warn if last incremental is old
1470                     if [[ "$inc_age_minutes" -gt 60 ]]; then
1471                         log_warning "Last incremental backup is ${inc_age_minutes} minutes old"
1472                         log_warning "  Verify that backup collection (--start) is running"
1473                         has_warnings=1
1474                     fi
1475                 fi
1476             else
1477                 log_warning "No incremental backup files found"
1478                 log_warning "  Start incremental backup collection with: pg_scribe --start"
1479                 has_warnings=1
1480             fi
1481
1482             # Check for metadata file
1483             if [[ -f "$FILE/pg_scribe_metadata.txt" ]]; then
1484                 echo -e "${BOLD}Metadata File:${RESET}   Present" >&2
1485
1486                 # Extract some metadata
1487                 local pg_version_line
1488                 pg_version_line=$(grep "PostgreSQL" "$FILE/pg_scribe_metadata.txt" 2>/dev/null | head -1)
1489                 if [[ -n "$pg_version_line" ]]; then
1490                     echo -e "${BOLD}Backup PG Version:${RESET} $pg_version_line" >&2
1491                 fi
1492             else
1493                 log_warning "Metadata file not found"
1494                 has_warnings=1
1495             fi
1496
1497             # Calculate total backup directory size
1498             local total_size
1499             total_size=$(du -sh "$FILE" 2>/dev/null | cut -f1)
1500             echo -e "${BOLD}Total Directory Size:${RESET} $total_size" >&2
1501         fi
1502     fi
1503
1504     # Overall health summary
1505     echo >&2
1506     log_step "Health Summary"
1507
1508     if [[ "$has_warnings" -eq 0 ]]; then
1509         log_success "System is healthy"
1510         echo >&2
1511         log_info "Replication slot is active and lag is acceptable"
1512         if [[ -n "$FILE" ]]; then
1513             log_info "Backup directory appears healthy"
1514         fi
1515         exit "$EXIT_SUCCESS"
1516     else
1517         log_warning "System has warnings - review messages above"
1518         echo >&2
1519         log_info "Address any CRITICAL or WARNING issues promptly"
1520         log_info "See design doc for monitoring recommendations"
1521         exit "$EXIT_WARNING"
1522     fi
1523 }
1524
1525 # Main entry point
1526 main() {
1527     parse_args "$@"
1528
1529     case "$ACTION" in
1530         init)
1531             cmd_init
1532             ;;
1533         start)
1534             cmd_start
1535             ;;
1536         full-backup)
1537             cmd_full_backup
1538             ;;
1539         restore)
1540             cmd_restore
1541             ;;
1542         status)
1543             cmd_status
1544             ;;
1545         *)
1546             log_error "Unknown action: $ACTION"
1547             exit "$EXIT_GENERAL_ERROR"
1548             ;;
1549     esac
1550 }
1551
1552 # Run main with all arguments
1553 main "$@"