diff --git a/cmd/pitr.go b/cmd/pitr.go index 29ec791..04b2a9a 100644 --- a/cmd/pitr.go +++ b/cmd/pitr.go @@ -162,6 +162,27 @@ Example: RunE: runWALCleanup, } +// walTimelineCmd shows timeline history +var walTimelineCmd = &cobra.Command{ + Use: "timeline", + Short: "Show timeline branching history", + Long: `Display PostgreSQL timeline history and branching structure. + +Timelines track recovery points and allow parallel recovery paths. +A new timeline is created each time you perform point-in-time recovery. + +Shows: +- Timeline hierarchy and parent relationships +- Timeline switch points (LSN) +- WAL segment ranges per timeline +- Reason for timeline creation + +Example: + dbbackup wal timeline --archive-dir /backups/wal_archive +`, + RunE: runWALTimeline, +} + func init() { rootCmd.AddCommand(pitrCmd) rootCmd.AddCommand(walCmd) @@ -175,6 +196,7 @@ func init() { walCmd.AddCommand(walArchiveCmd) walCmd.AddCommand(walListCmd) walCmd.AddCommand(walCleanupCmd) + walCmd.AddCommand(walTimelineCmd) // PITR enable flags pitrEnableCmd.Flags().StringVar(&pitrArchiveDir, "archive-dir", "/var/backups/wal_archive", "Directory to store WAL archives") @@ -194,6 +216,9 @@ func init() { // WAL cleanup flags walCleanupCmd.Flags().StringVar(&walArchiveDir, "archive-dir", "/var/backups/wal_archive", "WAL archive directory") walCleanupCmd.Flags().IntVar(&walRetentionDays, "retention-days", 7, "Days to keep WAL archives") + + // WAL timeline flags + walTimelineCmd.Flags().StringVar(&walArchiveDir, "archive-dir", "/var/backups/wal_archive", "WAL archive directory") } // Command implementations @@ -424,6 +449,56 @@ func runWALCleanup(cmd *cobra.Command, args []string) error { return nil } +func runWALTimeline(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + // Create timeline manager + tm := wal.NewTimelineManager(log) + + // Parse timeline history + history, err := tm.ParseTimelineHistory(ctx, walArchiveDir) + if err != nil { + return fmt.Errorf("failed to parse timeline history: %w", err) + } + + // Validate consistency + if err := tm.ValidateTimelineConsistency(ctx, history); err != nil { + log.Warn("Timeline consistency issues detected", "error", err) + } + + // Display timeline tree + fmt.Println(tm.FormatTimelineTree(history)) + + // Display timeline details + if len(history.Timelines) > 0 { + fmt.Println("\nTimeline Details:") + fmt.Println("═════════════════") + for _, tl := range history.Timelines { + fmt.Printf("\nTimeline %d:\n", tl.TimelineID) + if tl.ParentTimeline > 0 { + fmt.Printf(" Parent: Timeline %d\n", tl.ParentTimeline) + fmt.Printf(" Switch LSN: %s\n", tl.SwitchPoint) + } + if tl.Reason != "" { + fmt.Printf(" Reason: %s\n", tl.Reason) + } + if tl.FirstWALSegment > 0 { + fmt.Printf(" WAL Range: 0x%016X - 0x%016X\n", tl.FirstWALSegment, tl.LastWALSegment) + segmentCount := tl.LastWALSegment - tl.FirstWALSegment + 1 + fmt.Printf(" Segments: %d files (~%d MB)\n", segmentCount, segmentCount*16) + } + if !tl.CreatedAt.IsZero() { + fmt.Printf(" Created: %s\n", tl.CreatedAt.Format("2006-01-02 15:04:05")) + } + if tl.TimelineID == history.CurrentTimeline { + fmt.Printf(" Status: ⚡ CURRENT\n") + } + } + } + + return nil +} + // Helper functions func formatWALSize(bytes int64) string { diff --git a/internal/wal/timeline.go b/internal/wal/timeline.go new file mode 100644 index 0000000..8a74314 --- /dev/null +++ b/internal/wal/timeline.go @@ -0,0 +1,418 @@ +package wal + +import ( + "bufio" + "context" + "fmt" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "time" + + "dbbackup/internal/logger" +) + +// TimelineManager manages PostgreSQL timeline history and tracking +type TimelineManager struct { + log logger.Logger +} + +// NewTimelineManager creates a new timeline manager +func NewTimelineManager(log logger.Logger) *TimelineManager { + return &TimelineManager{ + log: log, + } +} + +// TimelineInfo represents information about a PostgreSQL timeline +type TimelineInfo struct { + TimelineID uint32 // Timeline identifier (1, 2, 3, etc.) + ParentTimeline uint32 // Parent timeline ID (0 for timeline 1) + SwitchPoint string // LSN where timeline switch occurred + Reason string // Reason for timeline switch (from .history file) + HistoryFile string // Path to .history file + FirstWALSegment uint64 // First WAL segment in this timeline + LastWALSegment uint64 // Last known WAL segment in this timeline + CreatedAt time.Time // When timeline was created +} + +// TimelineHistory represents the complete timeline branching structure +type TimelineHistory struct { + Timelines []*TimelineInfo // All timelines sorted by ID + CurrentTimeline uint32 // Current active timeline + TimelineMap map[uint32]*TimelineInfo // Quick lookup by timeline ID +} + +// ParseTimelineHistory parses timeline history from an archive directory +func (tm *TimelineManager) ParseTimelineHistory(ctx context.Context, archiveDir string) (*TimelineHistory, error) { + tm.log.Info("Parsing timeline history", "archive_dir", archiveDir) + + history := &TimelineHistory{ + Timelines: make([]*TimelineInfo, 0), + TimelineMap: make(map[uint32]*TimelineInfo), + } + + // Find all .history files in archive directory + historyFiles, err := filepath.Glob(filepath.Join(archiveDir, "*.history")) + if err != nil { + return nil, fmt.Errorf("failed to find timeline history files: %w", err) + } + + // Parse each history file + for _, histFile := range historyFiles { + timeline, err := tm.parseHistoryFile(histFile) + if err != nil { + tm.log.Warn("Failed to parse history file", "file", histFile, "error", err) + continue + } + history.Timelines = append(history.Timelines, timeline) + history.TimelineMap[timeline.TimelineID] = timeline + } + + // Always add timeline 1 (base timeline) if not present + if _, exists := history.TimelineMap[1]; !exists { + baseTimeline := &TimelineInfo{ + TimelineID: 1, + ParentTimeline: 0, + SwitchPoint: "0/0", + Reason: "Base timeline", + FirstWALSegment: 0, + } + history.Timelines = append(history.Timelines, baseTimeline) + history.TimelineMap[1] = baseTimeline + } + + // Sort timelines by ID + sort.Slice(history.Timelines, func(i, j int) bool { + return history.Timelines[i].TimelineID < history.Timelines[j].TimelineID + }) + + // Scan WAL files to populate segment ranges + if err := tm.scanWALSegments(archiveDir, history); err != nil { + tm.log.Warn("Failed to scan WAL segments", "error", err) + } + + // Determine current timeline (highest timeline ID with WAL files) + for i := len(history.Timelines) - 1; i >= 0; i-- { + if history.Timelines[i].LastWALSegment > 0 { + history.CurrentTimeline = history.Timelines[i].TimelineID + break + } + } + if history.CurrentTimeline == 0 { + history.CurrentTimeline = 1 // Default to timeline 1 + } + + tm.log.Info("Timeline history parsed", + "timelines", len(history.Timelines), + "current_timeline", history.CurrentTimeline) + + return history, nil +} + +// parseHistoryFile parses a single .history file +// Format: +// Example: 00000001.history contains "1 0/3000000 no recovery target specified" +func (tm *TimelineManager) parseHistoryFile(path string) (*TimelineInfo, error) { + // Extract timeline ID from filename (e.g., "00000002.history" -> 2) + filename := filepath.Base(path) + if !strings.HasSuffix(filename, ".history") { + return nil, fmt.Errorf("invalid history file name: %s", filename) + } + + timelineStr := strings.TrimSuffix(filename, ".history") + timelineID64, err := strconv.ParseUint(timelineStr, 16, 32) + if err != nil { + return nil, fmt.Errorf("invalid timeline ID in filename %s: %w", filename, err) + } + timelineID := uint32(timelineID64) + + // Read file content + file, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("failed to open history file: %w", err) + } + defer file.Close() + + stat, err := file.Stat() + if err != nil { + return nil, fmt.Errorf("failed to stat history file: %w", err) + } + + timeline := &TimelineInfo{ + TimelineID: timelineID, + HistoryFile: path, + CreatedAt: stat.ModTime(), + } + + // Parse history entries (last line is the most recent) + scanner := bufio.NewScanner(file) + var lastLine string + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line != "" && !strings.HasPrefix(line, "#") { + lastLine = line + } + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("error reading history file: %w", err) + } + + // Parse the last line: "parentTLI switchpoint reason" + if lastLine != "" { + parts := strings.SplitN(lastLine, "\t", 3) + if len(parts) >= 2 { + // Parent timeline + parentTLI64, err := strconv.ParseUint(strings.TrimSpace(parts[0]), 16, 32) + if err == nil { + timeline.ParentTimeline = uint32(parentTLI64) + } + + // Switch point (LSN) + timeline.SwitchPoint = strings.TrimSpace(parts[1]) + + // Reason (optional) + if len(parts) >= 3 { + timeline.Reason = strings.TrimSpace(parts[2]) + } + } + } + + return timeline, nil +} + +// scanWALSegments scans the archive directory to populate segment ranges for each timeline +func (tm *TimelineManager) scanWALSegments(archiveDir string, history *TimelineHistory) error { + // Find all WAL files (including compressed/encrypted) + patterns := []string{"*", "*.gz", "*.enc", "*.gz.enc"} + walFiles := make([]string, 0) + + for _, pattern := range patterns { + matches, err := filepath.Glob(filepath.Join(archiveDir, pattern)) + if err != nil { + continue + } + walFiles = append(walFiles, matches...) + } + + // Process each WAL file + for _, walFile := range walFiles { + filename := filepath.Base(walFile) + + // Remove extensions + filename = strings.TrimSuffix(filename, ".gz.enc") + filename = strings.TrimSuffix(filename, ".enc") + filename = strings.TrimSuffix(filename, ".gz") + + // Skip non-WAL files + if len(filename) != 24 { + continue + } + + // Parse WAL filename: TTTTTTTTXXXXXXXXYYYYYYYY + // T = Timeline (8 hex), X = Log file (8 hex), Y = Segment (8 hex) + timelineID64, err := strconv.ParseUint(filename[0:8], 16, 32) + if err != nil { + continue + } + timelineID := uint32(timelineID64) + + segmentID64, err := strconv.ParseUint(filename[8:24], 16, 64) + if err != nil { + continue + } + + // Update timeline info + if tl, exists := history.TimelineMap[timelineID]; exists { + if tl.FirstWALSegment == 0 || segmentID64 < tl.FirstWALSegment { + tl.FirstWALSegment = segmentID64 + } + if segmentID64 > tl.LastWALSegment { + tl.LastWALSegment = segmentID64 + } + } + } + + return nil +} + +// ValidateTimelineConsistency validates that the timeline chain is consistent +func (tm *TimelineManager) ValidateTimelineConsistency(ctx context.Context, history *TimelineHistory) error { + tm.log.Info("Validating timeline consistency", "timelines", len(history.Timelines)) + + // Check that each timeline (except 1) has a valid parent + for _, tl := range history.Timelines { + if tl.TimelineID == 1 { + continue // Base timeline has no parent + } + + if tl.ParentTimeline == 0 { + return fmt.Errorf("timeline %d has no parent timeline", tl.TimelineID) + } + + parent, exists := history.TimelineMap[tl.ParentTimeline] + if !exists { + return fmt.Errorf("timeline %d references non-existent parent timeline %d", + tl.TimelineID, tl.ParentTimeline) + } + + // Verify parent timeline has WAL files up to the switch point + if parent.LastWALSegment == 0 { + tm.log.Warn("Parent timeline has no WAL segments", + "timeline", tl.TimelineID, + "parent", tl.ParentTimeline) + } + } + + tm.log.Info("Timeline consistency validated", "timelines", len(history.Timelines)) + return nil +} + +// GetTimelinePath returns the path from timeline 1 to the target timeline +func (tm *TimelineManager) GetTimelinePath(history *TimelineHistory, targetTimeline uint32) ([]*TimelineInfo, error) { + path := make([]*TimelineInfo, 0) + + currentTL := targetTimeline + for currentTL > 0 { + tl, exists := history.TimelineMap[currentTL] + if !exists { + return nil, fmt.Errorf("timeline %d not found in history", currentTL) + } + + // Prepend to path (we're walking backwards) + path = append([]*TimelineInfo{tl}, path...) + + // Move to parent + if currentTL == 1 { + break // Reached base timeline + } + currentTL = tl.ParentTimeline + + // Prevent infinite loops + if len(path) > 100 { + return nil, fmt.Errorf("timeline path too long (possible cycle)") + } + } + + return path, nil +} + +// FindTimelineAtPoint finds which timeline was active at a given LSN +func (tm *TimelineManager) FindTimelineAtPoint(history *TimelineHistory, targetLSN string) (uint32, error) { + // Start from current timeline and walk backwards + for i := len(history.Timelines) - 1; i >= 0; i-- { + tl := history.Timelines[i] + + // Compare LSNs (simplified - in production would need proper LSN comparison) + if tl.SwitchPoint <= targetLSN || tl.SwitchPoint == "0/0" { + return tl.TimelineID, nil + } + } + + // Default to timeline 1 + return 1, nil +} + +// GetRequiredWALFiles returns all WAL files needed for recovery to a target timeline +func (tm *TimelineManager) GetRequiredWALFiles(ctx context.Context, history *TimelineHistory, archiveDir string, targetTimeline uint32) ([]string, error) { + tm.log.Info("Finding required WAL files", "target_timeline", targetTimeline) + + // Get timeline path from base to target + path, err := tm.GetTimelinePath(history, targetTimeline) + if err != nil { + return nil, fmt.Errorf("failed to get timeline path: %w", err) + } + + requiredFiles := make([]string, 0) + + // Collect WAL files for each timeline in the path + for _, tl := range path { + // Find all WAL files for this timeline + pattern := fmt.Sprintf("%08X*", tl.TimelineID) + matches, err := filepath.Glob(filepath.Join(archiveDir, pattern)) + if err != nil { + return nil, fmt.Errorf("failed to find WAL files for timeline %d: %w", tl.TimelineID, err) + } + + requiredFiles = append(requiredFiles, matches...) + + // Also include the .history file + historyFile := filepath.Join(archiveDir, fmt.Sprintf("%08X.history", tl.TimelineID)) + if _, err := os.Stat(historyFile); err == nil { + requiredFiles = append(requiredFiles, historyFile) + } + } + + tm.log.Info("Required WAL files collected", + "files", len(requiredFiles), + "timelines", len(path)) + + return requiredFiles, nil +} + +// FormatTimelineTree returns a formatted string showing the timeline branching structure +func (tm *TimelineManager) FormatTimelineTree(history *TimelineHistory) string { + if len(history.Timelines) == 0 { + return "No timelines found" + } + + var sb strings.Builder + sb.WriteString("Timeline Branching Structure:\n") + sb.WriteString("═════════════════════════════\n\n") + + // Build tree recursively + tm.formatTimelineNode(&sb, history, 1, 0, "") + + return sb.String() +} + +// formatTimelineNode recursively formats a timeline node and its children +func (tm *TimelineManager) formatTimelineNode(sb *strings.Builder, history *TimelineHistory, timelineID uint32, depth int, prefix string) { + tl, exists := history.TimelineMap[timelineID] + if !exists { + return + } + + // Format current node + indent := strings.Repeat(" ", depth) + marker := "├─" + if depth == 0 { + marker = "●" + } + + sb.WriteString(fmt.Sprintf("%s%s Timeline %d", indent, marker, tl.TimelineID)) + + if tl.TimelineID == history.CurrentTimeline { + sb.WriteString(" [CURRENT]") + } + + if tl.SwitchPoint != "" && tl.SwitchPoint != "0/0" { + sb.WriteString(fmt.Sprintf(" (switched at %s)", tl.SwitchPoint)) + } + + if tl.FirstWALSegment > 0 { + sb.WriteString(fmt.Sprintf("\n%s WAL segments: %d files", indent, tl.LastWALSegment-tl.FirstWALSegment+1)) + } + + if tl.Reason != "" { + sb.WriteString(fmt.Sprintf("\n%s Reason: %s", indent, tl.Reason)) + } + + sb.WriteString("\n") + + // Find and format children + children := make([]*TimelineInfo, 0) + for _, child := range history.Timelines { + if child.ParentTimeline == timelineID { + children = append(children, child) + } + } + + // Recursively format children + for _, child := range children { + tm.formatTimelineNode(sb, history, child.TimelineID, depth+1, prefix) + } +}