Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a09d5d672c | |||
| 5792ce883c |
35
CHANGELOG.md
35
CHANGELOG.md
@ -5,6 +5,41 @@ All notable changes to dbbackup will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [4.2.8] - 2026-01-30
|
||||
|
||||
### Added - MEDIUM Priority Features
|
||||
|
||||
- **#10: WAL Archive Statistics (MEDIUM priority)**
|
||||
- `dbbackup pitr status` now shows comprehensive WAL archive statistics
|
||||
- Displays: total files, total size, compression rate, oldest/newest WAL, time span
|
||||
- Auto-detects archive directory from PostgreSQL `archive_command`
|
||||
- Supports compressed (.gz, .zst, .lz4) and encrypted (.enc) WAL files
|
||||
- **Problem**: No visibility into WAL archive health and growth
|
||||
- **Solution**: Real-time stats in PITR status command, helps identify retention issues
|
||||
|
||||
**Example Output:**
|
||||
```
|
||||
WAL Archive Statistics:
|
||||
======================================================
|
||||
Total Files: 1,234
|
||||
Total Size: 19.8 GB
|
||||
Average Size: 16.4 MB
|
||||
Compressed: 1,234 files (68.5% saved)
|
||||
Encrypted: 1,234 files
|
||||
|
||||
Oldest WAL: 000000010000000000000042
|
||||
Created: 2026-01-15 08:30:00
|
||||
Newest WAL: 000000010000000000004D2F
|
||||
Created: 2026-01-30 17:45:30
|
||||
Time Span: 15.4 days
|
||||
```
|
||||
|
||||
**Files Modified:**
|
||||
- `internal/wal/archiver.go`: Extended `ArchiveStats` struct with detailed fields
|
||||
- `internal/wal/archiver.go`: Added `GetArchiveStats()`, `FormatArchiveStats()` functions
|
||||
- `cmd/pitr.go`: Integrated stats into `pitr status` command
|
||||
- `cmd/pitr.go`: Added `extractArchiveDirFromCommand()` helper
|
||||
|
||||
## [4.2.7] - 2026-01-30
|
||||
|
||||
### Added - HIGH Priority Features
|
||||
|
||||
58
cmd/pitr.go
58
cmd/pitr.go
@ -5,6 +5,7 @@ import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
@ -505,12 +506,24 @@ func runPITRStatus(cmd *cobra.Command, args []string) error {
|
||||
|
||||
// Show WAL archive statistics if archive directory can be determined
|
||||
if config.ArchiveCommand != "" {
|
||||
// Extract archive dir from command (simple parsing)
|
||||
fmt.Println()
|
||||
fmt.Println("WAL Archive Statistics:")
|
||||
fmt.Println("======================================================")
|
||||
// TODO: Parse archive dir and show stats
|
||||
fmt.Println(" (Use 'dbbackup wal list --archive-dir <dir>' to view archives)")
|
||||
archiveDir := extractArchiveDirFromCommand(config.ArchiveCommand)
|
||||
if archiveDir != "" {
|
||||
fmt.Println()
|
||||
fmt.Println("WAL Archive Statistics:")
|
||||
fmt.Println("======================================================")
|
||||
stats, err := wal.GetArchiveStats(archiveDir)
|
||||
if err != nil {
|
||||
fmt.Printf(" ⚠ Could not read archive: %v\n", err)
|
||||
fmt.Printf(" (Archive directory: %s)\n", archiveDir)
|
||||
} else {
|
||||
fmt.Print(wal.FormatArchiveStats(stats))
|
||||
}
|
||||
} else {
|
||||
fmt.Println()
|
||||
fmt.Println("WAL Archive Statistics:")
|
||||
fmt.Println("======================================================")
|
||||
fmt.Println(" (Use 'dbbackup wal list --archive-dir <dir>' to view archives)")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
@ -1309,3 +1322,36 @@ func runMySQLPITREnable(cmd *cobra.Command, args []string) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractArchiveDirFromCommand attempts to extract the archive directory
|
||||
// from a PostgreSQL archive_command string
|
||||
// Example: "dbbackup wal archive %p %f --archive-dir=/mnt/wal" → "/mnt/wal"
|
||||
func extractArchiveDirFromCommand(command string) string {
|
||||
// Look for common patterns:
|
||||
// 1. --archive-dir=/path
|
||||
// 2. --archive-dir /path
|
||||
// 3. Plain path argument
|
||||
|
||||
parts := strings.Fields(command)
|
||||
for i, part := range parts {
|
||||
// Pattern: --archive-dir=/path
|
||||
if strings.HasPrefix(part, "--archive-dir=") {
|
||||
return strings.TrimPrefix(part, "--archive-dir=")
|
||||
}
|
||||
// Pattern: --archive-dir /path
|
||||
if part == "--archive-dir" && i+1 < len(parts) {
|
||||
return parts[i+1]
|
||||
}
|
||||
}
|
||||
|
||||
// If command contains dbbackup, the last argument might be the archive dir
|
||||
if strings.Contains(command, "dbbackup") && len(parts) > 2 {
|
||||
lastArg := parts[len(parts)-1]
|
||||
// Check if it looks like a path
|
||||
if strings.HasPrefix(lastArg, "/") || strings.HasPrefix(lastArg, "./") {
|
||||
return lastArg
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
@ -367,6 +367,11 @@ type ArchiveStats struct {
|
||||
TotalSize int64 `json:"total_size"`
|
||||
OldestArchive time.Time `json:"oldest_archive"`
|
||||
NewestArchive time.Time `json:"newest_archive"`
|
||||
OldestWAL string `json:"oldest_wal,omitempty"`
|
||||
NewestWAL string `json:"newest_wal,omitempty"`
|
||||
TimeSpan string `json:"time_span,omitempty"`
|
||||
AvgFileSize int64 `json:"avg_file_size,omitempty"`
|
||||
CompressionRate float64 `json:"compression_rate,omitempty"`
|
||||
}
|
||||
|
||||
// FormatSize returns human-readable size
|
||||
@ -389,3 +394,199 @@ func (s *ArchiveStats) FormatSize() string {
|
||||
return fmt.Sprintf("%d B", s.TotalSize)
|
||||
}
|
||||
}
|
||||
|
||||
// GetArchiveStats scans a WAL archive directory and returns comprehensive statistics
|
||||
func GetArchiveStats(archiveDir string) (*ArchiveStats, error) {
|
||||
stats := &ArchiveStats{
|
||||
OldestArchive: time.Now(),
|
||||
NewestArchive: time.Time{},
|
||||
}
|
||||
|
||||
// Check if directory exists
|
||||
if _, err := os.Stat(archiveDir); os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("archive directory does not exist: %s", archiveDir)
|
||||
}
|
||||
|
||||
type walFileInfo struct {
|
||||
name string
|
||||
size int64
|
||||
modTime time.Time
|
||||
}
|
||||
|
||||
var walFiles []walFileInfo
|
||||
var compressedSize int64
|
||||
var originalSize int64
|
||||
|
||||
// Walk the archive directory
|
||||
err := filepath.Walk(archiveDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil // Skip files we can't read
|
||||
}
|
||||
|
||||
// Skip directories
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if this is a WAL file (including compressed/encrypted variants)
|
||||
name := info.Name()
|
||||
if !isWALFileName(name) {
|
||||
return nil
|
||||
}
|
||||
|
||||
stats.TotalFiles++
|
||||
stats.TotalSize += info.Size()
|
||||
|
||||
// Track compressed/encrypted files
|
||||
if strings.HasSuffix(name, ".gz") || strings.HasSuffix(name, ".zst") || strings.HasSuffix(name, ".lz4") {
|
||||
stats.CompressedFiles++
|
||||
compressedSize += info.Size()
|
||||
// Estimate original size (WAL files are typically 16MB)
|
||||
originalSize += 16 * 1024 * 1024
|
||||
}
|
||||
if strings.HasSuffix(name, ".enc") || strings.Contains(name, ".encrypted") {
|
||||
stats.EncryptedFiles++
|
||||
}
|
||||
|
||||
// Track oldest/newest
|
||||
if info.ModTime().Before(stats.OldestArchive) {
|
||||
stats.OldestArchive = info.ModTime()
|
||||
stats.OldestWAL = name
|
||||
}
|
||||
if info.ModTime().After(stats.NewestArchive) {
|
||||
stats.NewestArchive = info.ModTime()
|
||||
stats.NewestWAL = name
|
||||
}
|
||||
|
||||
// Store file info for additional calculations
|
||||
walFiles = append(walFiles, walFileInfo{
|
||||
name: name,
|
||||
size: info.Size(),
|
||||
modTime: info.ModTime(),
|
||||
})
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan archive directory: %w", err)
|
||||
}
|
||||
|
||||
// Return early if no WAL files found
|
||||
if stats.TotalFiles == 0 {
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// Calculate average file size
|
||||
stats.AvgFileSize = stats.TotalSize / int64(stats.TotalFiles)
|
||||
|
||||
// Calculate compression rate if we have compressed files
|
||||
if stats.CompressedFiles > 0 && originalSize > 0 {
|
||||
stats.CompressionRate = (1.0 - float64(compressedSize)/float64(originalSize)) * 100.0
|
||||
}
|
||||
|
||||
// Calculate time span
|
||||
duration := stats.NewestArchive.Sub(stats.OldestArchive)
|
||||
stats.TimeSpan = formatDuration(duration)
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// isWALFileName checks if a filename looks like a PostgreSQL WAL file
|
||||
func isWALFileName(name string) bool {
|
||||
// Strip compression/encryption extensions
|
||||
baseName := name
|
||||
baseName = strings.TrimSuffix(baseName, ".gz")
|
||||
baseName = strings.TrimSuffix(baseName, ".zst")
|
||||
baseName = strings.TrimSuffix(baseName, ".lz4")
|
||||
baseName = strings.TrimSuffix(baseName, ".enc")
|
||||
baseName = strings.TrimSuffix(baseName, ".encrypted")
|
||||
|
||||
// PostgreSQL WAL files are 24 hex characters (e.g., 000000010000000000000001)
|
||||
// Also accept .backup and .history files
|
||||
if len(baseName) == 24 {
|
||||
// Check if all hex
|
||||
for _, c := range baseName {
|
||||
if !((c >= '0' && c <= '9') || (c >= 'A' && c <= 'F') || (c >= 'a' && c <= 'f')) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Accept .backup and .history files
|
||||
if strings.HasSuffix(baseName, ".backup") || strings.HasSuffix(baseName, ".history") {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// formatDuration formats a duration into a human-readable string
|
||||
func formatDuration(d time.Duration) string {
|
||||
if d < time.Hour {
|
||||
return fmt.Sprintf("%.0f minutes", d.Minutes())
|
||||
}
|
||||
if d < 24*time.Hour {
|
||||
return fmt.Sprintf("%.1f hours", d.Hours())
|
||||
}
|
||||
days := d.Hours() / 24
|
||||
if days < 30 {
|
||||
return fmt.Sprintf("%.1f days", days)
|
||||
}
|
||||
if days < 365 {
|
||||
return fmt.Sprintf("%.1f months", days/30)
|
||||
}
|
||||
return fmt.Sprintf("%.1f years", days/365)
|
||||
}
|
||||
|
||||
// FormatArchiveStats formats archive statistics for display
|
||||
func FormatArchiveStats(stats *ArchiveStats) string {
|
||||
if stats.TotalFiles == 0 {
|
||||
return " No WAL files found in archive"
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(fmt.Sprintf(" Total Files: %d\n", stats.TotalFiles))
|
||||
sb.WriteString(fmt.Sprintf(" Total Size: %s\n", stats.FormatSize()))
|
||||
|
||||
if stats.AvgFileSize > 0 {
|
||||
const (
|
||||
KB = 1024
|
||||
MB = 1024 * KB
|
||||
)
|
||||
avgSize := float64(stats.AvgFileSize)
|
||||
if avgSize >= MB {
|
||||
sb.WriteString(fmt.Sprintf(" Average Size: %.2f MB\n", avgSize/MB))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf(" Average Size: %.2f KB\n", avgSize/KB))
|
||||
}
|
||||
}
|
||||
|
||||
if stats.CompressedFiles > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" Compressed: %d files", stats.CompressedFiles))
|
||||
if stats.CompressionRate > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" (%.1f%% saved)", stats.CompressionRate))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
if stats.EncryptedFiles > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" Encrypted: %d files\n", stats.EncryptedFiles))
|
||||
}
|
||||
|
||||
if stats.OldestWAL != "" {
|
||||
sb.WriteString(fmt.Sprintf("\n Oldest WAL: %s\n", stats.OldestWAL))
|
||||
sb.WriteString(fmt.Sprintf(" Created: %s\n", stats.OldestArchive.Format("2006-01-02 15:04:05")))
|
||||
}
|
||||
if stats.NewestWAL != "" {
|
||||
sb.WriteString(fmt.Sprintf(" Newest WAL: %s\n", stats.NewestWAL))
|
||||
sb.WriteString(fmt.Sprintf(" Created: %s\n", stats.NewestArchive.Format("2006-01-02 15:04:05")))
|
||||
}
|
||||
if stats.TimeSpan != "" {
|
||||
sb.WriteString(fmt.Sprintf(" Time Span: %s\n", stats.TimeSpan))
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user