Compare commits

...

2 Commits

Author SHA1 Message Date
0e050b2def fix: cluster backup TUI success report formatting consistency
All checks were successful
CI/CD / Test (push) Successful in 1m15s
CI/CD / Lint (push) Successful in 1m24s
CI/CD / Build & Release (push) Successful in 3m10s
- Aligned box width and indentation with restore success screen
- Removed inconsistent 2-space prefix from success/error boxes
- Standardized content indentation to 4 spaces
- Moved timing section outside else block (always shown)
- Updated footer style to match restore screen
2026-01-17 16:15:16 +01:00
62d58c77af feat(restore): add --parallel-dbs=-1 auto-detection based on CPU/RAM
All checks were successful
CI/CD / Test (push) Successful in 1m16s
CI/CD / Lint (push) Successful in 1m25s
CI/CD / Build & Release (push) Successful in 3m14s
- Add CalculateOptimalParallel() function to preflight.go
- Calculates optimal workers: min(RAM/3GB, CPU cores), capped at 16
- Reduces parallelism by 50% if memory pressure >80%
- Add -1 flag value for auto-detection mode
- Preflight summary now shows CPU cores and recommended parallel
2026-01-17 13:41:28 +01:00
4 changed files with 157 additions and 46 deletions

View File

@@ -4,8 +4,8 @@ This directory contains pre-compiled binaries for the DB Backup Tool across mult
## Build Information
- **Version**: 3.42.50
- **Build Time**: 2026-01-17_06:48:35_UTC
- **Git Commit**: dd1db84
- **Build Time**: 2026-01-17_12:41:47_UTC
- **Git Commit**: 62d58c7
## Recent Updates (v1.1.0)
- ✅ Fixed TUI progress display with line-by-line output

View File

@@ -290,7 +290,7 @@ func init() {
restoreClusterCmd.Flags().BoolVar(&restoreForce, "force", false, "Skip safety checks and confirmations")
restoreClusterCmd.Flags().BoolVar(&restoreCleanCluster, "clean-cluster", false, "Drop all existing user databases before restore (disaster recovery)")
restoreClusterCmd.Flags().IntVar(&restoreJobs, "jobs", 0, "Number of parallel decompression jobs (0 = auto)")
restoreClusterCmd.Flags().IntVar(&restoreParallelDBs, "parallel-dbs", 0, "Number of databases to restore in parallel (0 = use config default, 1 = sequential)")
restoreClusterCmd.Flags().IntVar(&restoreParallelDBs, "parallel-dbs", 0, "Number of databases to restore in parallel (0 = use config default, 1 = sequential, -1 = auto-detect based on CPU/RAM)")
restoreClusterCmd.Flags().StringVar(&restoreWorkdir, "workdir", "", "Working directory for extraction (use when system disk is small, e.g. /mnt/storage/restore_tmp)")
restoreClusterCmd.Flags().BoolVar(&restoreVerbose, "verbose", false, "Show detailed restore progress")
restoreClusterCmd.Flags().BoolVar(&restoreNoProgress, "no-progress", false, "Disable progress indicators")
@@ -786,7 +786,12 @@ func runRestoreCluster(cmd *cobra.Command, args []string) error {
}
// Override cluster parallelism if --parallel-dbs is specified
if restoreParallelDBs > 0 {
if restoreParallelDBs == -1 {
// Auto-detect optimal parallelism based on system resources
autoParallel := restore.CalculateOptimalParallel()
cfg.ClusterParallelism = autoParallel
log.Info("Auto-detected optimal parallelism for database restores", "parallel_dbs", autoParallel, "mode", "auto")
} else if restoreParallelDBs > 0 {
cfg.ClusterParallelism = restoreParallelDBs
log.Info("Using custom parallelism for database restores", "parallel_dbs", restoreParallelDBs)
}

View File

@@ -16,6 +16,57 @@ import (
"github.com/shirou/gopsutil/v3/mem"
)
// CalculateOptimalParallel returns the recommended number of parallel workers
// based on available system resources (CPU cores and RAM).
// This is a standalone function that can be called from anywhere.
// Returns 0 if resources cannot be detected.
func CalculateOptimalParallel() int {
cpuCores := runtime.NumCPU()
vmem, err := mem.VirtualMemory()
if err != nil {
// Fallback: use half of CPU cores if memory detection fails
if cpuCores > 1 {
return cpuCores / 2
}
return 1
}
memAvailableGB := float64(vmem.Available) / (1024 * 1024 * 1024)
// Each pg_restore worker needs approximately 2-4GB of RAM
// Use conservative 3GB per worker to avoid OOM
const memPerWorkerGB = 3.0
// Calculate limits
maxByMem := int(memAvailableGB / memPerWorkerGB)
maxByCPU := cpuCores
// Use the minimum of memory and CPU limits
recommended := maxByMem
if maxByCPU < recommended {
recommended = maxByCPU
}
// Apply sensible bounds
if recommended < 1 {
recommended = 1
}
if recommended > 16 {
recommended = 16 // Cap at 16 to avoid diminishing returns
}
// If memory pressure is high (>80%), reduce parallelism
if vmem.UsedPercent > 80 && recommended > 1 {
recommended = recommended / 2
if recommended < 1 {
recommended = 1
}
}
return recommended
}
// PreflightResult contains all preflight check results
type PreflightResult struct {
// Linux system checks
@@ -40,6 +91,8 @@ type LinuxChecks struct {
MemTotal uint64 // Total RAM in bytes
MemAvailable uint64 // Available RAM in bytes
MemUsedPercent float64 // Memory usage percentage
CPUCores int // Number of CPU cores
RecommendedParallel int // Auto-calculated optimal parallel count
ShmMaxOK bool // Is shmmax sufficient?
ShmAllOK bool // Is shmall sufficient?
MemAvailableOK bool // Is available RAM sufficient?
@@ -100,6 +153,7 @@ func (e *Engine) RunPreflightChecks(ctx context.Context, dumpsDir string, entrie
// checkSystemResources uses gopsutil for cross-platform system checks
func (e *Engine) checkSystemResources(result *PreflightResult) {
result.Linux.IsLinux = runtime.GOOS == "linux"
result.Linux.CPUCores = runtime.NumCPU()
// Get memory info (works on Linux, macOS, Windows, BSD)
if vmem, err := mem.VirtualMemory(); err == nil {
@@ -118,6 +172,9 @@ func (e *Engine) checkSystemResources(result *PreflightResult) {
e.log.Warn("Could not detect system memory", "error", err)
}
// Calculate recommended parallel based on resources
result.Linux.RecommendedParallel = e.calculateRecommendedParallel(result)
// Linux-specific kernel checks (shmmax, shmall)
if result.Linux.IsLinux {
e.checkLinuxKernel(result)
@@ -434,6 +491,56 @@ func (e *Engine) calculateRecommendations(result *PreflightResult) {
"recommended_locks", lockBoost)
}
// calculateRecommendedParallel determines optimal parallelism based on system resources
// Returns the recommended number of parallel workers for pg_restore
func (e *Engine) calculateRecommendedParallel(result *PreflightResult) int {
cpuCores := result.Linux.CPUCores
if cpuCores == 0 {
cpuCores = runtime.NumCPU()
}
memAvailableGB := float64(result.Linux.MemAvailable) / (1024 * 1024 * 1024)
// Each pg_restore worker needs approximately 2-4GB of RAM
// Use conservative 3GB per worker to avoid OOM
const memPerWorkerGB = 3.0
// Calculate limits
maxByMem := int(memAvailableGB / memPerWorkerGB)
maxByCPU := cpuCores
// Use the minimum of memory and CPU limits
recommended := maxByMem
if maxByCPU < recommended {
recommended = maxByCPU
}
// Apply sensible bounds
if recommended < 1 {
recommended = 1
}
if recommended > 16 {
recommended = 16 // Cap at 16 to avoid diminishing returns
}
// If memory pressure is high (>80%), reduce parallelism
if result.Linux.MemUsedPercent > 80 && recommended > 1 {
recommended = recommended / 2
if recommended < 1 {
recommended = 1
}
}
e.log.Info("Calculated recommended parallel",
"cpu_cores", cpuCores,
"mem_available_gb", fmt.Sprintf("%.1f", memAvailableGB),
"max_by_mem", maxByMem,
"max_by_cpu", maxByCPU,
"recommended", recommended)
return recommended
}
// printPreflightSummary prints a nice summary of all checks
func (e *Engine) printPreflightSummary(result *PreflightResult) {
fmt.Println()
@@ -446,6 +553,8 @@ func (e *Engine) printPreflightSummary(result *PreflightResult) {
printCheck("Total RAM", humanize.Bytes(result.Linux.MemTotal), true)
printCheck("Available RAM", humanize.Bytes(result.Linux.MemAvailable), result.Linux.MemAvailableOK || result.Linux.MemAvailable == 0)
printCheck("Memory Usage", fmt.Sprintf("%.1f%%", result.Linux.MemUsedPercent), result.Linux.MemUsedPercent < 85)
printCheck("CPU Cores", fmt.Sprintf("%d", result.Linux.CPUCores), true)
printCheck("Recommended Parallel", fmt.Sprintf("%d (auto-calculated)", result.Linux.RecommendedParallel), true)
// Linux-specific kernel checks
if result.Linux.IsLinux && result.Linux.ShmMax > 0 {

View File

@@ -455,25 +455,24 @@ func (m BackupExecutionModel) View() string {
// Show completion summary with detailed stats
if m.err != nil {
s.WriteString("\n")
s.WriteString(errorStyle.Render(" ╔══════════════════════════════════════════════════════════╗"))
s.WriteString(errorStyle.Render("╔══════════════════════════════════════════════════════════════╗"))
s.WriteString("\n")
s.WriteString(errorStyle.Render(" ║ [FAIL] BACKUP FAILED ║"))
s.WriteString(errorStyle.Render("║ [FAIL] BACKUP FAILED ║"))
s.WriteString("\n")
s.WriteString(errorStyle.Render(" ╚══════════════════════════════════════════════════════════╝"))
s.WriteString(errorStyle.Render("╚══════════════════════════════════════════════════════════════╝"))
s.WriteString("\n\n")
s.WriteString(errorStyle.Render(fmt.Sprintf(" Error: %v", m.err)))
s.WriteString("\n")
} else {
s.WriteString(successStyle.Render("╔══════════════════════════════════════════════════════════════╗"))
s.WriteString("\n")
s.WriteString(successStyle.Render(" ╔══════════════════════════════════════════════════════════╗"))
s.WriteString(successStyle.Render(" [OK] BACKUP COMPLETED SUCCESSFULLY ║"))
s.WriteString("\n")
s.WriteString(successStyle.Render(" ║ [OK] BACKUP COMPLETED SUCCESSFULLY ║"))
s.WriteString("\n")
s.WriteString(successStyle.Render(" ╚══════════════════════════════════════════════════════════╝"))
s.WriteString(successStyle.Render("╚══════════════════════════════════════════════════════════════╝"))
s.WriteString("\n\n")
// Summary section
s.WriteString(infoStyle.Render(" ─── Summary ─────────────────────────────────────────────"))
s.WriteString(infoStyle.Render(" ─── Summary ───────────────────────────────────────────────"))
s.WriteString("\n\n")
// Backup type specific info
@@ -493,26 +492,24 @@ func (m BackupExecutionModel) View() string {
}
s.WriteString("\n")
}
// Timing section
s.WriteString(infoStyle.Render(" ─── Timing ──────────────────────────────────────────────"))
// Timing section (always shown, consistent with restore)
s.WriteString(infoStyle.Render(" ─── Timing ────────────────────────────────────────────────"))
s.WriteString("\n\n")
elapsed := time.Since(m.startTime)
s.WriteString(fmt.Sprintf(" Total Time: %s\n", formatBackupDuration(elapsed)))
if m.backupType == "cluster" && m.dbTotal > 0 {
if m.backupType == "cluster" && m.dbTotal > 0 && m.err == nil {
avgPerDB := elapsed / time.Duration(m.dbTotal)
s.WriteString(fmt.Sprintf(" Avg per DB: %s\n", formatBackupDuration(avgPerDB)))
}
s.WriteString("\n")
s.WriteString(infoStyle.Render(" ─────────────────────────────────────────────────────────"))
s.WriteString("\n")
}
s.WriteString("\n")
s.WriteString(" [KEY] Press Enter or ESC to return to menu\n")
s.WriteString(infoStyle.Render(" ───────────────────────────────────────────────────────────"))
s.WriteString("\n\n")
s.WriteString(infoStyle.Render(" [KEYS] Press Enter to continue"))
}
return s.String()