Compare commits

...

2 Commits

Author SHA1 Message Date
a09d5d672c Bump version to 4.2.8
Some checks failed
CI/CD / Integration Tests (push) Has been skipped
CI/CD / Test (push) Failing after 1m17s
CI/CD / Lint (push) Failing after 1m7s
CI/CD / Build & Release (push) Has been skipped
2026-01-30 18:10:07 +01:00
5792ce883c feat: Add WAL archive statistics (#10 MEDIUM priority)
- Comprehensive WAL archive stats in 'pitr status' command
- Shows: file count, size, compression rate, oldest/newest, time span
- Auto-detects archive dir from PostgreSQL archive_command
- Supports compressed/encrypted WAL files
- Memory: ~90% reduction in TUI operations (from v4.2.7)
2026-01-30 18:09:58 +01:00
4 changed files with 289 additions and 7 deletions

View File

@ -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

View File

@ -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 ""
}

View File

@ -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()
}

View File

@ -16,7 +16,7 @@ import (
// Build information (set by ldflags)
var (
version = "4.2.7"
version = "4.2.8"
buildTime = "unknown"
gitCommit = "unknown"
)