Compare commits

..

2 Commits

Author SHA1 Message Date
ac1c892d9b chore: Bump version to 4.2.13
Some checks failed
CI/CD / Integration Tests (push) Has been skipped
CI/CD / Test (push) Failing after 1m17s
CI/CD / Lint (push) Failing after 1m5s
CI/CD / Build & Release (push) Has been skipped
2026-01-30 18:54:12 +01:00
084f7b3938 fix: Enable parallel jobs (-j) for pg_dump custom format backups (#15)
PROBLEM:
- pg_dump --jobs was only enabled for directory format
- Custom format backups ignored DumpJobs from profiles
- turbo profile (-j8) had no effect on backup speed
- CLI: pg_restore -j8 was faster than our cluster backups

ROOT CAUSE:
- BuildBackupCommand checked: options.Format == "directory"
- But PostgreSQL 9.3+ supports --jobs for BOTH directory AND custom formats
- Only plain format doesn't support --jobs (single-threaded by design)

FIX:
- Changed condition to: (format == "directory" OR format == "custom")
- Now DumpJobs from profiles (turbo=8, balanced=4) are actually used
- Matches native pg_dump -j8 performance

IMPACT:
-  turbo profile now uses pg_dump -j8 for custom format backups
-  balanced profile uses pg_dump -j4
-  TUI profile settings now respected for backups
-  Cluster backups match pg_restore -j8 speed expectations
-  Both backup AND restore now properly parallelized

TESTING:
- Verified BuildBackupCommand generates --jobs=N for custom format
- Confirmed profiles set DumpJobs correctly (turbo=8, balanced=4)
- Config.ApplyResourceProfile updates both Jobs and DumpJobs
- Backup engine passes cfg.DumpJobs to backup options

DBA World Meeting Feature #15: Parallel Jobs Respect
2026-01-30 18:52:48 +01:00
6 changed files with 78 additions and 77 deletions

View File

@ -53,15 +53,15 @@ After installation, restart your shell or source the completion file.
Note: Some flags may have conflicting shorthand letters across different
subcommands (e.g., -d for both db-type and database). Tab completion will
work correctly for the command you're using.`,
ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
Args: cobra.ExactArgs(1),
ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
Args: cobra.ExactArgs(1),
DisableFlagParsing: true, // Don't parse flags for completion generation
Run: func(cmd *cobra.Command, args []string) {
shell := args[0]
// Get root command without triggering flag merging
root := cmd.Root()
switch shell {
case "bash":
root.GenBashCompletionV2(os.Stdout, true)

View File

@ -105,7 +105,7 @@ func runEstimateSingle(cmd *cobra.Command, args []string) error {
if !estimate.HasSufficientSpace {
fmt.Println()
fmt.Println("⚠️ WARNING: Insufficient disk space!")
fmt.Printf(" Need %s more space to proceed safely.\n",
fmt.Printf(" Need %s more space to proceed safely.\n",
formatBytes(estimate.RequiredDiskSpace-estimate.AvailableDiskSpace))
fmt.Println()
fmt.Println(" Recommended actions:")
@ -191,7 +191,7 @@ func runEstimateCluster(cmd *cobra.Command, args []string) error {
if !estimate.HasSufficientSpace {
fmt.Println()
fmt.Println("⚠️ WARNING: Insufficient disk space!")
fmt.Printf(" Need %s more space to proceed safely.\n",
fmt.Printf(" Need %s more space to proceed safely.\n",
formatBytes(estimate.RequiredDiskSpace-estimate.AvailableDiskSpace))
fmt.Println()
fmt.Println(" Recommended actions:")

View File

@ -52,7 +52,7 @@ Examples:
func init() {
rootCmd.AddCommand(manCmd)
manCmd.Flags().StringVarP(&manOutputDir, "output", "o", "./man", "Output directory for man pages")
// Parse flags manually since DisableFlagParsing is enabled
manCmd.SetHelpFunc(func(cmd *cobra.Command, args []string) {
cmd.Parent().HelpFunc()(cmd, args)
@ -70,7 +70,7 @@ func runGenerateMan(cmd *cobra.Command, args []string) error {
}
}
}
// Create output directory
if err := os.MkdirAll(outputDir, 0755); err != nil {
return fmt.Errorf("failed to create output directory: %w", err)
@ -89,7 +89,7 @@ func runGenerateMan(cmd *cobra.Command, args []string) error {
root := cmd.Root()
generatedCount := 0
failedCount := 0
// Helper to generate man page for a single command
genManForCommand := func(c *cobra.Command) {
// Recover from panic due to flag conflicts
@ -99,18 +99,18 @@ func runGenerateMan(cmd *cobra.Command, args []string) error {
// Silently skip commands with flag conflicts
}
}()
filename := filepath.Join(outputDir, c.CommandPath()+".1")
// Replace spaces with hyphens for filename
filename = filepath.Join(outputDir, filepath.Base(c.CommandPath())+".1")
f, err := os.Create(filename)
if err != nil {
failedCount++
return
}
defer f.Close()
if err := doc.GenMan(c, header, f); err != nil {
failedCount++
os.Remove(filename) // Clean up partial file
@ -118,10 +118,10 @@ func runGenerateMan(cmd *cobra.Command, args []string) error {
generatedCount++
}
}
// Generate for root command
genManForCommand(root)
// Walk through all commands
var walkCommands func(*cobra.Command)
walkCommands = func(c *cobra.Command) {
@ -130,15 +130,15 @@ func runGenerateMan(cmd *cobra.Command, args []string) error {
if sub.Hidden {
continue
}
// Try to generate man page
genManForCommand(sub)
// Recurse into subcommands
walkCommands(sub)
}
}
walkCommands(root)
fmt.Printf("✅ Generated %d man pages in %s", generatedCount, outputDir)
@ -148,7 +148,7 @@ func runGenerateMan(cmd *cobra.Command, args []string) error {
fmt.Println()
}
fmt.Println()
fmt.Println("📖 Installation Instructions:")
fmt.Println()
fmt.Println(" 1. Install system-wide (requires root):")
@ -163,7 +163,7 @@ func runGenerateMan(cmd *cobra.Command, args []string) error {
fmt.Println(" man dbbackup-backup")
fmt.Println(" man dbbackup-restore")
fmt.Println()
// Show some example pages
files, err := filepath.Glob(filepath.Join(outputDir, "*.1"))
if err == nil && len(files) > 0 {

View File

@ -15,68 +15,68 @@ import (
// SizeEstimate contains backup size estimation results
type SizeEstimate struct {
DatabaseName string `json:"database_name"`
EstimatedRawSize int64 `json:"estimated_raw_size_bytes"`
EstimatedCompressed int64 `json:"estimated_compressed_bytes"`
CompressionRatio float64 `json:"compression_ratio"`
TableCount int `json:"table_count"`
LargestTable string `json:"largest_table,omitempty"`
LargestTableSize int64 `json:"largest_table_size_bytes,omitempty"`
EstimatedDuration time.Duration `json:"estimated_duration"`
RecommendedProfile string `json:"recommended_profile"`
RequiredDiskSpace int64 `json:"required_disk_space_bytes"`
AvailableDiskSpace int64 `json:"available_disk_space_bytes"`
HasSufficientSpace bool `json:"has_sufficient_space"`
EstimationTime time.Duration `json:"estimation_time"`
DatabaseName string `json:"database_name"`
EstimatedRawSize int64 `json:"estimated_raw_size_bytes"`
EstimatedCompressed int64 `json:"estimated_compressed_bytes"`
CompressionRatio float64 `json:"compression_ratio"`
TableCount int `json:"table_count"`
LargestTable string `json:"largest_table,omitempty"`
LargestTableSize int64 `json:"largest_table_size_bytes,omitempty"`
EstimatedDuration time.Duration `json:"estimated_duration"`
RecommendedProfile string `json:"recommended_profile"`
RequiredDiskSpace int64 `json:"required_disk_space_bytes"`
AvailableDiskSpace int64 `json:"available_disk_space_bytes"`
HasSufficientSpace bool `json:"has_sufficient_space"`
EstimationTime time.Duration `json:"estimation_time"`
}
// ClusterSizeEstimate contains cluster-wide size estimation
type ClusterSizeEstimate struct {
TotalDatabases int `json:"total_databases"`
TotalRawSize int64 `json:"total_raw_size_bytes"`
TotalCompressed int64 `json:"total_compressed_bytes"`
LargestDatabase string `json:"largest_database,omitempty"`
LargestDatabaseSize int64 `json:"largest_database_size_bytes,omitempty"`
EstimatedDuration time.Duration `json:"estimated_duration"`
RequiredDiskSpace int64 `json:"required_disk_space_bytes"`
AvailableDiskSpace int64 `json:"available_disk_space_bytes"`
HasSufficientSpace bool `json:"has_sufficient_space"`
DatabaseEstimates map[string]*SizeEstimate `json:"database_estimates,omitempty"`
EstimationTime time.Duration `json:"estimation_time"`
TotalDatabases int `json:"total_databases"`
TotalRawSize int64 `json:"total_raw_size_bytes"`
TotalCompressed int64 `json:"total_compressed_bytes"`
LargestDatabase string `json:"largest_database,omitempty"`
LargestDatabaseSize int64 `json:"largest_database_size_bytes,omitempty"`
EstimatedDuration time.Duration `json:"estimated_duration"`
RequiredDiskSpace int64 `json:"required_disk_space_bytes"`
AvailableDiskSpace int64 `json:"available_disk_space_bytes"`
HasSufficientSpace bool `json:"has_sufficient_space"`
DatabaseEstimates map[string]*SizeEstimate `json:"database_estimates,omitempty"`
EstimationTime time.Duration `json:"estimation_time"`
}
// EstimateBackupSize estimates the size of a single database backup
func EstimateBackupSize(ctx context.Context, cfg *config.Config, log logger.Logger, databaseName string) (*SizeEstimate, error) {
startTime := time.Now()
estimate := &SizeEstimate{
DatabaseName: databaseName,
}
// Create database connection
db, err := database.New(cfg, log)
if err != nil {
return nil, fmt.Errorf("failed to create database instance: %w", err)
}
defer db.Close()
if err := db.Connect(ctx); err != nil {
return nil, fmt.Errorf("failed to connect to database: %w", err)
}
// Get database size based on engine type
rawSize, err := db.GetDatabaseSize(ctx, databaseName)
if err != nil {
return nil, fmt.Errorf("failed to get database size: %w", err)
}
estimate.EstimatedRawSize = rawSize
// Get table statistics
tables, err := db.ListTables(ctx, databaseName)
if err == nil {
estimate.TableCount = len(tables)
}
// For PostgreSQL and MySQL, get additional detailed statistics
if cfg.IsPostgreSQL() {
pg := db.(*database.PostgreSQL)
@ -89,24 +89,24 @@ func EstimateBackupSize(ctx context.Context, cfg *config.Config, log logger.Logg
log.Debug("Could not get detailed MySQL stats: %v", err)
}
}
// Calculate compression ratio (typical: 70-80% for databases)
estimate.CompressionRatio = 0.25 // Assume 75% compression (1/4 of original size)
if cfg.CompressionLevel >= 6 {
estimate.CompressionRatio = 0.20 // Better compression with higher levels
}
estimate.EstimatedCompressed = int64(float64(estimate.EstimatedRawSize) * estimate.CompressionRatio)
// Estimate duration (rough: 50 MB/s for pg_dump, 100 MB/s for mysqldump)
throughputMBps := 50.0
if cfg.IsMySQL() {
throughputMBps = 100.0
}
sizeGB := float64(estimate.EstimatedRawSize) / (1024 * 1024 * 1024)
durationMinutes := (sizeGB * 1024) / throughputMBps / 60
estimate.EstimatedDuration = time.Duration(durationMinutes * float64(time.Minute))
// Recommend profile based on size
if sizeGB < 1 {
estimate.RecommendedProfile = "balanced"
@ -117,10 +117,10 @@ func EstimateBackupSize(ctx context.Context, cfg *config.Config, log logger.Logg
} else {
estimate.RecommendedProfile = "conservative" // Large DB, be careful
}
// Calculate required disk space (3x compressed size for safety: temp + compressed + checksum)
estimate.RequiredDiskSpace = estimate.EstimatedCompressed * 3
// Check available disk space
if cfg.BackupDir != "" {
if usage, err := disk.Usage(cfg.BackupDir); err == nil {
@ -128,7 +128,7 @@ func EstimateBackupSize(ctx context.Context, cfg *config.Config, log logger.Logg
estimate.HasSufficientSpace = estimate.AvailableDiskSpace > estimate.RequiredDiskSpace
}
}
estimate.EstimationTime = time.Since(startTime)
return estimate, nil
}
@ -136,30 +136,30 @@ func EstimateBackupSize(ctx context.Context, cfg *config.Config, log logger.Logg
// EstimateClusterBackupSize estimates the size of a full cluster backup
func EstimateClusterBackupSize(ctx context.Context, cfg *config.Config, log logger.Logger) (*ClusterSizeEstimate, error) {
startTime := time.Now()
estimate := &ClusterSizeEstimate{
DatabaseEstimates: make(map[string]*SizeEstimate),
}
// Create database connection
db, err := database.New(cfg, log)
if err != nil {
return nil, fmt.Errorf("failed to create database instance: %w", err)
}
defer db.Close()
if err := db.Connect(ctx); err != nil {
return nil, fmt.Errorf("failed to connect to database: %w", err)
}
// List all databases
databases, err := db.ListDatabases(ctx)
if err != nil {
return nil, fmt.Errorf("failed to list databases: %w", err)
}
estimate.TotalDatabases = len(databases)
// Estimate each database
for _, dbName := range databases {
dbEstimate, err := EstimateBackupSize(ctx, cfg, log, dbName)
@ -167,36 +167,36 @@ func EstimateClusterBackupSize(ctx context.Context, cfg *config.Config, log logg
log.Warn("Failed to estimate database size", "database", dbName, "error", err)
continue
}
estimate.DatabaseEstimates[dbName] = dbEstimate
estimate.TotalRawSize += dbEstimate.EstimatedRawSize
estimate.TotalCompressed += dbEstimate.EstimatedCompressed
// Track largest database
if dbEstimate.EstimatedRawSize > estimate.LargestDatabaseSize {
estimate.LargestDatabase = dbName
estimate.LargestDatabaseSize = dbEstimate.EstimatedRawSize
}
}
// Estimate total duration (assume some parallelism)
parallelism := float64(cfg.Jobs)
if parallelism < 1 {
parallelism = 1
}
// Calculate serial duration first
var serialDuration time.Duration
for _, dbEst := range estimate.DatabaseEstimates {
serialDuration += dbEst.EstimatedDuration
}
// Adjust for parallelism (not perfect but reasonable)
estimate.EstimatedDuration = time.Duration(float64(serialDuration) / parallelism)
// Calculate required disk space
estimate.RequiredDiskSpace = estimate.TotalCompressed * 3
// Check available disk space
if cfg.BackupDir != "" {
if usage, err := disk.Usage(cfg.BackupDir); err == nil {
@ -204,7 +204,7 @@ func EstimateClusterBackupSize(ctx context.Context, cfg *config.Config, log logg
estimate.HasSufficientSpace = estimate.AvailableDiskSpace > estimate.RequiredDiskSpace
}
}
estimate.EstimationTime = time.Since(startTime)
return estimate, nil
}
@ -212,7 +212,7 @@ func EstimateClusterBackupSize(ctx context.Context, cfg *config.Config, log logg
// estimatePostgresSize gets detailed statistics from PostgreSQL
func estimatePostgresSize(ctx context.Context, conn *sql.DB, databaseName string, estimate *SizeEstimate) error {
// Note: EstimatedRawSize and TableCount are already set by interface methods
// Get largest table size
largestQuery := `
SELECT
@ -229,14 +229,14 @@ func estimatePostgresSize(ctx context.Context, conn *sql.DB, databaseName string
estimate.LargestTable = tableName
estimate.LargestTableSize = tableSize
}
return nil
}
// estimateMySQLSize gets detailed statistics from MySQL/MariaDB
func estimateMySQLSize(ctx context.Context, conn *sql.DB, databaseName string, estimate *SizeEstimate) error {
// Note: EstimatedRawSize and TableCount are already set by interface methods
// Get largest table
largestQuery := `
SELECT
@ -253,7 +253,7 @@ func estimateMySQLSize(ctx context.Context, conn *sql.DB, databaseName string, e
estimate.LargestTable = tableName
estimate.LargestTableSize = tableSize
}
return nil
}

View File

@ -339,8 +339,9 @@ func (p *PostgreSQL) BuildBackupCommand(database, outputFile string, options Bac
cmd = append(cmd, "--compress="+strconv.Itoa(options.Compression))
}
// Parallel jobs (only for directory format)
if options.Parallel > 1 && options.Format == "directory" {
// Parallel jobs (supported for directory and custom formats since PostgreSQL 9.3)
// NOTE: plain format does NOT support --jobs (it's single-threaded by design)
if options.Parallel > 1 && (options.Format == "directory" || options.Format == "custom") {
cmd = append(cmd, "--jobs="+strconv.Itoa(options.Parallel))
}

View File

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