Compare commits

..

5 Commits

Author SHA1 Message Date
a759f4d3db v3.42.72: Fix Large DB Mode not applying when changing profiles
All checks were successful
CI/CD / Test (push) Successful in 1m15s
CI/CD / Lint (push) Successful in 1m25s
CI/CD / Build & Release (push) Successful in 3m13s
Critical fix: Large DB Mode settings (reduced parallelism, increased locks)
were not being reapplied when user changed resource profiles in Settings.

This caused cluster restores to fail with 'out of shared memory' errors on
low-resource VMs even when Large DB Mode was enabled.

Now ApplyResourceProfile() properly applies LargeDBMode modifiers after
setting base profile values, ensuring parallelism stays reduced.

Example: balanced profile with Large DB Mode:
- ClusterParallelism: 4 → 2 (halved)
- MaxLocksPerTxn: 2048 → 8192 (increased)

This allows successful cluster restores on low-spec VMs.
2026-01-18 21:23:35 +01:00
7cf1d6f85b Update bin/README.md for v3.42.71
Some checks failed
CI/CD / Test (push) Successful in 1m15s
CI/CD / Lint (push) Successful in 1m25s
CI/CD / Build & Release (push) Has been cancelled
2026-01-18 21:17:11 +01:00
b305d1342e v3.42.71: Fix error message formatting + code alignment
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 3m12s
- Fixed incorrect diagnostic message for shared memory exhaustion
  (was 'Cannot access file', now 'PostgreSQL lock table exhausted')
- Improved clarity of lock capacity formula display
- Code formatting: aligned struct field comments for consistency
- Files affected: restore_exec.go, backup_exec.go, persist.go
2026-01-18 21:13:35 +01:00
5456da7183 Update bin/README.md for v3.42.70
All checks were successful
CI/CD / Test (push) Successful in 1m15s
CI/CD / Lint (push) Successful in 1m26s
CI/CD / Build & Release (push) Has been skipped
2026-01-18 18:53:44 +01:00
f9ff45cf2a v3.42.70: TUI consistency improvements - unified backup/restore views
All checks were successful
CI/CD / Test (push) Successful in 1m15s
CI/CD / Lint (push) Successful in 1m23s
CI/CD / Build & Release (push) Successful in 3m11s
- Added phase constants (backupPhaseGlobals, backupPhaseDatabases, backupPhaseCompressing)
- Changed title from '[EXEC] Backup Execution' to '[EXEC] Cluster Backup'
- Made phase labels explicit with action verbs (Backing up Globals, Backing up Databases, Compressing Archive)
- Added realtime ETA tracking to backup phase 2 (databases) with phase2StartTime
- Moved duration display from top to bottom as 'Elapsed:' (consistent with restore)
- Standardized keys label to '[KEYS]' everywhere (was '[KEY]')
- Added timing fields: phase2StartTime, dbPhaseElapsed, dbAvgPerDB
- Created renderBackupDatabaseProgressBarWithTiming() with elapsed + ETA display
- Enhanced completion summary with archive info and throughput calculation
- Removed duplicate formatDuration() function (shared with restore_exec.go)

All 10 consistency improvements implemented (high/medium/low priority).
Backup and restore TUI views now provide unified professional UX.
2026-01-18 18:52:26 +01:00
5 changed files with 144 additions and 56 deletions

View File

@@ -4,8 +4,8 @@ This directory contains pre-compiled binaries for the DB Backup Tool across mult
## Build Information ## Build Information
- **Version**: 3.42.50 - **Version**: 3.42.50
- **Build Time**: 2026-01-18_17:17:17_UTC - **Build Time**: 2026-01-18_20:14:09_UTC
- **Git Commit**: a0a401c - **Git Commit**: b305d13
## Recent Updates (v1.1.0) ## Recent Updates (v1.1.0)
- ✅ Fixed TUI progress display with line-by-line output - ✅ Fixed TUI progress display with line-by-line output

View File

@@ -445,6 +445,12 @@ func (c *Config) ApplyResourceProfile(profileName string) error {
// Apply profile settings // Apply profile settings
c.ResourceProfile = profile.Name c.ResourceProfile = profile.Name
// If LargeDBMode is enabled, apply its modifiers
if c.LargeDBMode {
profile = cpu.ApplyLargeDBMode(profile)
}
c.ClusterParallelism = profile.ClusterParallelism c.ClusterParallelism = profile.ClusterParallelism
c.Jobs = profile.Jobs c.Jobs = profile.Jobs
c.DumpJobs = profile.DumpJobs c.DumpJobs = profile.DumpJobs

View File

@@ -30,7 +30,7 @@ type LocalConfig struct {
// Performance settings // Performance settings
CPUWorkload string CPUWorkload string
MaxCores int MaxCores int
ClusterTimeout int // Cluster operation timeout in minutes (default: 1440 = 24 hours) ClusterTimeout int // Cluster operation timeout in minutes (default: 1440 = 24 hours)
ResourceProfile string ResourceProfile string
LargeDBMode bool // Enable large database mode (reduces parallelism, increases locks) LargeDBMode bool // Enable large database mode (reduces parallelism, increases locks)

View File

@@ -13,6 +13,14 @@ import (
"dbbackup/internal/config" "dbbackup/internal/config"
"dbbackup/internal/database" "dbbackup/internal/database"
"dbbackup/internal/logger" "dbbackup/internal/logger"
"path/filepath"
)
// Backup phase constants for consistency
const (
backupPhaseGlobals = 1
backupPhaseDatabases = 2
backupPhaseCompressing = 3
) )
// BackupExecutionModel handles backup execution with progress // BackupExecutionModel handles backup execution with progress
@@ -31,27 +39,36 @@ type BackupExecutionModel struct {
cancelling bool // True when user has requested cancellation cancelling bool // True when user has requested cancellation
err error err error
result string result string
archivePath string // Path to created archive (for summary)
archiveSize int64 // Size of created archive (for summary)
startTime time.Time startTime time.Time
elapsed time.Duration // Final elapsed time
details []string details []string
spinnerFrame int spinnerFrame int
// Database count progress (for cluster backup) // Database count progress (for cluster backup)
dbTotal int dbTotal int
dbDone int dbDone int
dbName string // Current database being backed up dbName string // Current database being backed up
overallPhase int // 1=globals, 2=databases, 3=compressing overallPhase int // 1=globals, 2=databases, 3=compressing
phaseDesc string // Description of current phase phaseDesc string // Description of current phase
phase2StartTime time.Time // When phase 2 (databases) started (for realtime ETA)
dbPhaseElapsed time.Duration // Elapsed time since database backup phase started
dbAvgPerDB time.Duration // Average time per database backup
} }
// sharedBackupProgressState holds progress state that can be safely accessed from callbacks // sharedBackupProgressState holds progress state that can be safely accessed from callbacks
type sharedBackupProgressState struct { type sharedBackupProgressState struct {
mu sync.Mutex mu sync.Mutex
dbTotal int dbTotal int
dbDone int dbDone int
dbName string dbName string
overallPhase int // 1=globals, 2=databases, 3=compressing overallPhase int // 1=globals, 2=databases, 3=compressing
phaseDesc string // Description of current phase phaseDesc string // Description of current phase
hasUpdate bool hasUpdate bool
phase2StartTime time.Time // When phase 2 started (for realtime ETA calculation)
dbPhaseElapsed time.Duration // Elapsed time since database backup phase started
dbAvgPerDB time.Duration // Average time per database backup
} }
// Package-level shared progress state for backup operations // Package-level shared progress state for backup operations
@@ -72,12 +89,12 @@ func clearCurrentBackupProgress() {
currentBackupProgressState = nil currentBackupProgressState = nil
} }
func getCurrentBackupProgress() (dbTotal, dbDone int, dbName string, overallPhase int, phaseDesc string, hasUpdate bool) { func getCurrentBackupProgress() (dbTotal, dbDone int, dbName string, overallPhase int, phaseDesc string, hasUpdate bool, dbPhaseElapsed, dbAvgPerDB time.Duration, phase2StartTime time.Time) {
currentBackupProgressMu.Lock() currentBackupProgressMu.Lock()
defer currentBackupProgressMu.Unlock() defer currentBackupProgressMu.Unlock()
if currentBackupProgressState == nil { if currentBackupProgressState == nil {
return 0, 0, "", 0, "", false return 0, 0, "", 0, "", false, 0, 0, time.Time{}
} }
currentBackupProgressState.mu.Lock() currentBackupProgressState.mu.Lock()
@@ -86,9 +103,17 @@ func getCurrentBackupProgress() (dbTotal, dbDone int, dbName string, overallPhas
hasUpdate = currentBackupProgressState.hasUpdate hasUpdate = currentBackupProgressState.hasUpdate
currentBackupProgressState.hasUpdate = false currentBackupProgressState.hasUpdate = false
// Calculate realtime phase elapsed if we have a phase 2 start time
dbPhaseElapsed = currentBackupProgressState.dbPhaseElapsed
if !currentBackupProgressState.phase2StartTime.IsZero() {
dbPhaseElapsed = time.Since(currentBackupProgressState.phase2StartTime)
}
return currentBackupProgressState.dbTotal, currentBackupProgressState.dbDone, return currentBackupProgressState.dbTotal, currentBackupProgressState.dbDone,
currentBackupProgressState.dbName, currentBackupProgressState.overallPhase, currentBackupProgressState.dbName, currentBackupProgressState.overallPhase,
currentBackupProgressState.phaseDesc, hasUpdate currentBackupProgressState.phaseDesc, hasUpdate,
dbPhaseElapsed, currentBackupProgressState.dbAvgPerDB,
currentBackupProgressState.phase2StartTime
} }
func NewBackupExecution(cfg *config.Config, log logger.Logger, parent tea.Model, ctx context.Context, backupType, dbName string, ratio int) BackupExecutionModel { func NewBackupExecution(cfg *config.Config, log logger.Logger, parent tea.Model, ctx context.Context, backupType, dbName string, ratio int) BackupExecutionModel {
@@ -132,8 +157,11 @@ type backupProgressMsg struct {
} }
type backupCompleteMsg struct { type backupCompleteMsg struct {
result string result string
err error err error
archivePath string
archiveSize int64
elapsed time.Duration
} }
func executeBackupWithTUIProgress(parentCtx context.Context, cfg *config.Config, log logger.Logger, backupType, dbName string, ratio int) tea.Cmd { func executeBackupWithTUIProgress(parentCtx context.Context, cfg *config.Config, log logger.Logger, backupType, dbName string, ratio int) tea.Cmd {
@@ -176,9 +204,13 @@ func executeBackupWithTUIProgress(parentCtx context.Context, cfg *config.Config,
progressState.dbDone = done progressState.dbDone = done
progressState.dbTotal = total progressState.dbTotal = total
progressState.dbName = currentDB progressState.dbName = currentDB
progressState.overallPhase = 2 // Phase 2: Backing up databases progressState.overallPhase = backupPhaseDatabases
progressState.phaseDesc = fmt.Sprintf("Phase 2/3: Databases (%d/%d)", done, total) progressState.phaseDesc = fmt.Sprintf("Phase 2/3: Backing up Databases (%d/%d)", done, total)
progressState.hasUpdate = true progressState.hasUpdate = true
// Set phase 2 start time on first callback (for realtime ETA calculation)
if progressState.phase2StartTime.IsZero() {
progressState.phase2StartTime = time.Now()
}
progressState.mu.Unlock() progressState.mu.Unlock()
}) })
@@ -216,8 +248,9 @@ func executeBackupWithTUIProgress(parentCtx context.Context, cfg *config.Config,
} }
return backupCompleteMsg{ return backupCompleteMsg{
result: result, result: result,
err: nil, err: nil,
elapsed: elapsed,
} }
} }
} }
@@ -230,13 +263,15 @@ func (m BackupExecutionModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.spinnerFrame = (m.spinnerFrame + 1) % len(spinnerFrames) m.spinnerFrame = (m.spinnerFrame + 1) % len(spinnerFrames)
// Poll for database progress updates from callbacks // Poll for database progress updates from callbacks
dbTotal, dbDone, dbName, overallPhase, phaseDesc, hasUpdate := getCurrentBackupProgress() dbTotal, dbDone, dbName, overallPhase, phaseDesc, hasUpdate, dbPhaseElapsed, dbAvgPerDB, _ := getCurrentBackupProgress()
if hasUpdate { if hasUpdate {
m.dbTotal = dbTotal m.dbTotal = dbTotal
m.dbDone = dbDone m.dbDone = dbDone
m.dbName = dbName m.dbName = dbName
m.overallPhase = overallPhase m.overallPhase = overallPhase
m.phaseDesc = phaseDesc m.phaseDesc = phaseDesc
m.dbPhaseElapsed = dbPhaseElapsed
m.dbAvgPerDB = dbAvgPerDB
} }
// Update status based on progress and elapsed time // Update status based on progress and elapsed time
@@ -284,6 +319,7 @@ func (m BackupExecutionModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.done = true m.done = true
m.err = msg.err m.err = msg.err
m.result = msg.result m.result = msg.result
m.elapsed = msg.elapsed
if m.err == nil { if m.err == nil {
m.status = "[OK] Backup completed successfully!" m.status = "[OK] Backup completed successfully!"
} else { } else {
@@ -361,14 +397,52 @@ func renderBackupDatabaseProgressBar(done, total int, dbName string, width int)
return fmt.Sprintf(" Database: [%s] %d/%d", bar, done, total) return fmt.Sprintf(" Database: [%s] %d/%d", bar, done, total)
} }
// renderBackupDatabaseProgressBarWithTiming renders database backup progress with ETA
func renderBackupDatabaseProgressBarWithTiming(done, total int, dbPhaseElapsed, dbAvgPerDB time.Duration) string {
if total == 0 {
return ""
}
// Calculate progress percentage
percent := float64(done) / float64(total)
if percent > 1.0 {
percent = 1.0
}
// Build progress bar
barWidth := 50
filled := int(float64(barWidth) * percent)
if filled > barWidth {
filled = barWidth
}
bar := strings.Repeat("█", filled) + strings.Repeat("░", barWidth-filled)
// Calculate ETA similar to restore
var etaStr string
if done > 0 && done < total {
avgPerDB := dbPhaseElapsed / time.Duration(done)
remaining := total - done
eta := avgPerDB * time.Duration(remaining)
etaStr = fmt.Sprintf(" | ETA: %s", formatDuration(eta))
} else if done == total {
etaStr = " | Complete"
}
return fmt.Sprintf(" Databases: [%s] %d/%d | Elapsed: %s%s\n",
bar, done, total, formatDuration(dbPhaseElapsed), etaStr)
}
func (m BackupExecutionModel) View() string { func (m BackupExecutionModel) View() string {
var s strings.Builder var s strings.Builder
s.Grow(512) // Pre-allocate estimated capacity for better performance s.Grow(512) // Pre-allocate estimated capacity for better performance
// Clear screen with newlines and render header // Clear screen with newlines and render header
s.WriteString("\n\n") s.WriteString("\n\n")
header := titleStyle.Render("[EXEC] Backup Execution") header := "[EXEC] Backing up Database"
s.WriteString(header) if m.backupType == "cluster" {
header = "[EXEC] Cluster Backup"
}
s.WriteString(titleStyle.Render(header))
s.WriteString("\n\n") s.WriteString("\n\n")
// Backup details - properly aligned // Backup details - properly aligned
@@ -379,7 +453,6 @@ func (m BackupExecutionModel) View() string {
if m.ratio > 0 { if m.ratio > 0 {
s.WriteString(fmt.Sprintf(" %-10s %d\n", "Sample:", m.ratio)) s.WriteString(fmt.Sprintf(" %-10s %d\n", "Sample:", m.ratio))
} }
s.WriteString(fmt.Sprintf(" %-10s %s\n", "Duration:", time.Since(m.startTime).Round(time.Second)))
s.WriteString("\n") s.WriteString("\n")
// Status display // Status display
@@ -395,11 +468,15 @@ func (m BackupExecutionModel) View() string {
elapsedSec := int(time.Since(m.startTime).Seconds()) elapsedSec := int(time.Since(m.startTime).Seconds())
if m.overallPhase == 2 && m.dbTotal > 0 { if m.overallPhase == backupPhaseDatabases && m.dbTotal > 0 {
// Phase 2: Database backups - contributes 15-90% // Phase 2: Database backups - contributes 15-90%
dbPct := int((int64(m.dbDone) * 100) / int64(m.dbTotal)) dbPct := int((int64(m.dbDone) * 100) / int64(m.dbTotal))
overallProgress = 15 + (dbPct * 75 / 100) overallProgress = 15 + (dbPct * 75 / 100)
phaseLabel = m.phaseDesc phaseLabel = m.phaseDesc
} else if m.overallPhase == backupPhaseCompressing {
// Phase 3: Compressing archive
overallProgress = 92
phaseLabel = "Phase 3/3: Compressing Archive"
} else if elapsedSec < 5 { } else if elapsedSec < 5 {
// Initial setup // Initial setup
overallProgress = 2 overallProgress = 2
@@ -430,9 +507,9 @@ func (m BackupExecutionModel) View() string {
} }
s.WriteString("\n") s.WriteString("\n")
// Database progress bar // Database progress bar with timing
progressBar := renderBackupDatabaseProgressBar(m.dbDone, m.dbTotal, m.dbName, 50) s.WriteString(renderBackupDatabaseProgressBarWithTiming(m.dbDone, m.dbTotal, m.dbPhaseElapsed, m.dbAvgPerDB))
s.WriteString(progressBar + "\n") s.WriteString("\n")
} else { } else {
// Intermediate phase (globals) // Intermediate phase (globals)
spinner := spinnerFrames[m.spinnerFrame] spinner := spinnerFrames[m.spinnerFrame]
@@ -449,7 +526,10 @@ func (m BackupExecutionModel) View() string {
} }
if !m.cancelling { if !m.cancelling {
s.WriteString("\n [KEY] Press Ctrl+C or ESC to cancel\n") // Elapsed time
s.WriteString(fmt.Sprintf("Elapsed: %s\n", formatDuration(time.Since(m.startTime))))
s.WriteString("\n")
s.WriteString(infoStyle.Render("[KEYS] Press Ctrl+C or ESC to cancel"))
} }
} else { } else {
// Show completion summary with detailed stats // Show completion summary with detailed stats
@@ -474,6 +554,14 @@ func (m BackupExecutionModel) View() string {
s.WriteString(infoStyle.Render(" ─── Summary ───────────────────────────────────────────────")) s.WriteString(infoStyle.Render(" ─── Summary ───────────────────────────────────────────────"))
s.WriteString("\n\n") s.WriteString("\n\n")
// Archive info (if available)
if m.archivePath != "" {
s.WriteString(fmt.Sprintf(" Archive: %s\n", filepath.Base(m.archivePath)))
}
if m.archiveSize > 0 {
s.WriteString(fmt.Sprintf(" Archive Size: %s\n", FormatBytes(m.archiveSize)))
}
// Backup type specific info // Backup type specific info
switch m.backupType { switch m.backupType {
case "cluster": case "cluster":
@@ -497,12 +585,21 @@ func (m BackupExecutionModel) View() string {
s.WriteString(infoStyle.Render(" ─── Timing ────────────────────────────────────────────────")) s.WriteString(infoStyle.Render(" ─── Timing ────────────────────────────────────────────────"))
s.WriteString("\n\n") s.WriteString("\n\n")
elapsed := time.Since(m.startTime) elapsed := m.elapsed
s.WriteString(fmt.Sprintf(" Total Time: %s\n", formatBackupDuration(elapsed))) if elapsed == 0 {
elapsed = time.Since(m.startTime)
}
s.WriteString(fmt.Sprintf(" Total Time: %s\n", formatDuration(elapsed)))
// Calculate and show throughput if we have size info
if m.archiveSize > 0 && elapsed.Seconds() > 0 {
throughput := float64(m.archiveSize) / elapsed.Seconds()
s.WriteString(fmt.Sprintf(" Throughput: %s/s (average)\n", FormatBytes(int64(throughput))))
}
if m.backupType == "cluster" && m.dbTotal > 0 && m.err == nil { if m.backupType == "cluster" && m.dbTotal > 0 && m.err == nil {
avgPerDB := elapsed / time.Duration(m.dbTotal) avgPerDB := elapsed / time.Duration(m.dbTotal)
s.WriteString(fmt.Sprintf(" Avg per DB: %s\n", formatBackupDuration(avgPerDB))) s.WriteString(fmt.Sprintf(" Avg per DB: %s\n", formatDuration(avgPerDB)))
} }
s.WriteString("\n") s.WriteString("\n")
@@ -513,18 +610,3 @@ func (m BackupExecutionModel) View() string {
return s.String() return s.String()
} }
// formatBackupDuration formats duration in human readable format
func formatBackupDuration(d time.Duration) string {
if d < time.Minute {
return fmt.Sprintf("%.1fs", d.Seconds())
}
if d < time.Hour {
minutes := int(d.Minutes())
seconds := int(d.Seconds()) % 60
return fmt.Sprintf("%dm %ds", minutes, seconds)
}
hours := int(d.Hours())
minutes := int(d.Minutes()) % 60
return fmt.Sprintf("%dh %dm", hours, minutes)
}

View File

@@ -152,8 +152,8 @@ type sharedProgressState struct {
currentDB string currentDB string
// Timing info for database restore phase // Timing info for database restore phase
dbPhaseElapsed time.Duration // Elapsed time since restore phase started dbPhaseElapsed time.Duration // Elapsed time since restore phase started
dbAvgPerDB time.Duration // Average time per database restore dbAvgPerDB time.Duration // Average time per database restore
phase3StartTime time.Time // When phase 3 started (for realtime ETA calculation) phase3StartTime time.Time // When phase 3 started (for realtime ETA calculation)
// Overall phase tracking (1=Extract, 2=Globals, 3=Databases) // Overall phase tracking (1=Extract, 2=Globals, 3=Databases)
@@ -1171,12 +1171,12 @@ func formatRestoreError(errStr string) string {
// Provide specific recommendations based on error // Provide specific recommendations based on error
if strings.Contains(errStr, "out of shared memory") || strings.Contains(errStr, "max_locks_per_transaction") { if strings.Contains(errStr, "out of shared memory") || strings.Contains(errStr, "max_locks_per_transaction") {
s.WriteString(errorStyle.Render(" • Cannot access file: stat : no such file or directory\n")) s.WriteString(errorStyle.Render(" • PostgreSQL lock table exhausted\n"))
s.WriteString("\n") s.WriteString("\n")
s.WriteString(infoStyle.Render(" ─── [HINT] Recommendations ────────────────────────────────")) s.WriteString(infoStyle.Render(" ─── [HINT] Recommendations ────────────────────────────────"))
s.WriteString("\n\n") s.WriteString("\n\n")
s.WriteString(" Lock table exhausted. Total capacity = max_locks_per_transaction\n") s.WriteString(" Lock capacity = max_locks_per_transaction\n")
s.WriteString(" × (max_connections + max_prepared_transactions).\n\n") s.WriteString(" × (max_connections + max_prepared_transactions)\n\n")
s.WriteString(" If you reduced VM size or max_connections, you need higher\n") s.WriteString(" If you reduced VM size or max_connections, you need higher\n")
s.WriteString(" max_locks_per_transaction to compensate.\n\n") s.WriteString(" max_locks_per_transaction to compensate.\n\n")
s.WriteString(successStyle.Render(" FIX OPTIONS:\n")) s.WriteString(successStyle.Render(" FIX OPTIONS:\n"))