- Add .golangci.yml with minimal linters (govet, ineffassign) - Run gofmt -s and goimports on all files to fix formatting - Disable fieldalignment and copylocks checks in govet
419 lines
12 KiB
Go
419 lines
12 KiB
Go
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: <parentTLI> <switchpoint> <reason>
|
|
// 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)
|
|
}
|
|
}
|