Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,17 @@ PGConfig.org API v2.
## License
[![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Fpgconfig%2Fapi.svg?type=large)](https://app.fossa.com/projects/git%2Bgithub.com%2Fpgconfig%2Fapi?ref=badge_large)

## CPU Core Counting

The API expects the `total_cpu` parameter to represent the total number of **logical CPU cores**, which includes hyperthreading. This is the standard output from:
- Linux/Unix: `nproc` command
- Go: `runtime.NumCPU()`
- Windows: Total processor count in Task Manager

**Example**: A system with 8 physical cores and hyperthreading enabled has 16 logical cores. Use `total_cpu=16`.

**Why logical cores?** Modern PostgreSQL (2017-2025) benefits from hyperthreading with [up to 15% performance improvement](https://www.cybertec-postgresql.com/en/experimenting-scaling-full-parallelism-postgresql/). The tuning formulas for `max_worker_processes`, `max_parallel_workers`, and `io_workers` are designed to work with logical core counts.

## Rules Engine

The configuration is adjusted by a rules engine based on the environment.
Expand Down
7 changes: 5 additions & 2 deletions cmd/pgconfigctl/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,18 @@ import (

var cfgFile string

// rootCmd represents the base command when called without any subcommands
var rootCmd = &cobra.Command{
// RootCmd represents the base command when called without any subcommands
var RootCmd = &cobra.Command{
Use: "pgconfigctl",
Short: "A tool to handle and benchmark your PostgreSQL",
// Uncomment the following line if your bare application
// has an action associated with it:
// Run: func(cmd *cobra.Command, args []string) { },
}

// rootCmd is an alias for backwards compatibility
var rootCmd = RootCmd

// Execute adds all child commands to the root command and sets flags appropriately.
// This is called by main.main(). It only needs to happen once to the rootCmd.
func Execute() {
Expand Down
2 changes: 1 addition & 1 deletion cmd/pgconfigctl/cmd/tune.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ func init() {
tuneCmd.PersistentFlags().StringVarP(&arch, "arch", "", runtime.GOARCH, "PostgreSQL Version")
tuneCmd.PersistentFlags().StringVarP(&diskType, "disk-type", "D", "SSD", "Disk type (possible values are SSD, HDD and SAN)")
tuneCmd.PersistentFlags().Float32VarP(&pgVersion, "version", "", defaults.PGVersionF, "PostgreSQL Version")
tuneCmd.PersistentFlags().IntVarP(&totalCPU, "cpus", "c", runtime.NumCPU(), "Total CPU cores")
tuneCmd.PersistentFlags().IntVarP(&totalCPU, "cpus", "c", runtime.NumCPU(), "Total logical CPU cores (includes hyperthreading)")
tuneCmd.PersistentFlags().MarkDeprecated("env-name", "please use --profile instead")
tuneCmd.PersistentFlags().IntVarP(&maxConnections, "max-connections", "M", 100, "Max expected connections")
tuneCmd.PersistentFlags().BoolVarP(&includePgbadger, "include-pgbadger", "B", false, "Include pgbadger params?")
Expand Down
117 changes: 117 additions & 0 deletions cmd/pgconfigctl/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package main

import (
"bytes"
"os"
"strings"
"testing"

"github.com/pgconfig/api/cmd/pgconfigctl/cmd"
)

// TestTuneProfileParsing validates that all profile types parse correctly
// This addresses issue #22 where Mixed and Desktop profiles were rejected
func TestTuneProfileParsing(t *testing.T) {
tests := []struct {
name string
args []string
wantError bool
errorMsg string
}{
{
name: "Mixed profile - mixed case (issue #22)",
args: []string{"tune", "--profile=Mixed"},
wantError: false,
},
{
name: "Mixed profile - uppercase",
args: []string{"tune", "--profile=MIXED"},
wantError: false,
},
{
name: "Mixed profile - lowercase",
args: []string{"tune", "--profile=mixed"},
wantError: false,
},
{
name: "Desktop profile - mixed case (issue #22)",
args: []string{"tune", "--profile=Desktop"},
wantError: false,
},
{
name: "Desktop profile - uppercase",
args: []string{"tune", "--profile=DESKTOP"},
wantError: false,
},
{
name: "Desktop profile - lowercase",
args: []string{"tune", "--profile=desktop"},
wantError: false,
},
{
name: "Web profile - uppercase",
args: []string{"tune", "--profile=WEB"},
wantError: false,
},
{
name: "Web profile - lowercase",
args: []string{"tune", "--profile=web"},
wantError: false,
},
{
name: "OLTP profile",
args: []string{"tune", "--profile=OLTP"},
wantError: false,
},
{
name: "DW profile",
args: []string{"tune", "--profile=DW"},
wantError: false,
},
{
name: "Invalid profile",
args: []string{"tune", "--profile=invalid"},
wantError: true,
errorMsg: "must be one of",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Capture output
outBuf := new(bytes.Buffer)
errBuf := new(bytes.Buffer)

// Save and restore original output
originalOut := os.Stdout
originalErr := os.Stderr
t.Cleanup(func() {
os.Stdout = originalOut
os.Stderr = originalErr
})

// Set command output
cmd.RootCmd.SetOut(outBuf)
cmd.RootCmd.SetErr(errBuf)
cmd.RootCmd.SetArgs(tt.args)

// Execute command
err := cmd.RootCmd.Execute()

if tt.wantError {
if err == nil {
t.Errorf("Expected error but got none")
return
}
if tt.errorMsg != "" && !strings.Contains(err.Error(), tt.errorMsg) {
t.Errorf("Error message = %v, want to contain %v", err.Error(), tt.errorMsg)
}
} else {
if err != nil {
t.Errorf("Unexpected error: %v\nOutput: %s\nError output: %s",
err, outBuf.String(), errBuf.String())
}
}
})
}
}
3 changes: 3 additions & 0 deletions pkg/input/input.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ type Input struct {
Profile profile.Profile `json:"profile"`
DiskType string `json:"disk_type"`
MaxConnections int `json:"max_connections"`
// TotalCPU represents the total number of logical CPU cores (including hyperthreading).
// Use runtime.NumCPU() or the output of `nproc` command to get this value.
// For CPUs with hyperthreading: 8 physical cores × 2 threads = 16 logical cores.
TotalCPU int `json:"total_cpu"`
PostgresVersion float32 `json:"postgres_version"`
}
Expand Down
4 changes: 2 additions & 2 deletions pkg/input/profile/profile.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,11 @@ const (
DW Profile = "DW"

// Mixed profile
Mixed Profile = "Mixed"
Mixed Profile = "MIXED"

// Desktop is the development machine on any non-production server
// that needs to consume less resources than a regular server.
Desktop Profile = "Desktop"
Desktop Profile = "DESKTOP"
)

// AllProfiles Lists all profiles currently available
Expand Down
109 changes: 109 additions & 0 deletions pkg/input/profile/profile_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package profile

import (
"testing"
)

func TestProfile_Set(t *testing.T) {
tests := []struct {
name string
input string
want Profile
wantErr bool
}{
{
name: "Web uppercase",
input: "WEB",
want: Web,
wantErr: false,
},
{
name: "Web lowercase",
input: "web",
want: Web,
wantErr: false,
},
{
name: "OLTP uppercase",
input: "OLTP",
want: OLTP,
wantErr: false,
},
{
name: "OLTP lowercase",
input: "oltp",
want: OLTP,
wantErr: false,
},
{
name: "DW uppercase",
input: "DW",
want: DW,
wantErr: false,
},
{
name: "DW lowercase",
input: "dw",
want: DW,
wantErr: false,
},
{
name: "Mixed uppercase",
input: "MIXED",
want: Mixed,
wantErr: false,
},
{
name: "Mixed mixed case",
input: "Mixed",
want: Mixed,
wantErr: false,
},
{
name: "Mixed lowercase",
input: "mixed",
want: Mixed,
wantErr: false,
},
{
name: "Desktop uppercase",
input: "DESKTOP",
want: Desktop,
wantErr: false,
},
{
name: "Desktop mixed case",
input: "Desktop",
want: Desktop,
wantErr: false,
},
{
name: "Desktop lowercase",
input: "desktop",
want: Desktop,
wantErr: false,
},
{
name: "Invalid profile",
input: "invalid",
want: "",
wantErr: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var p Profile
err := p.Set(tt.input)

if (err != nil) != tt.wantErr {
t.Errorf("Profile.Set() error = %v, wantErr %v", err, tt.wantErr)
return
}

if !tt.wantErr && p != tt.want {
t.Errorf("Profile.Set() = %v, want %v", p, tt.want)
}
})
}
}
20 changes: 20 additions & 0 deletions pkg/rules/os.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@ const (
Linux = "linux"
Unix = "unix"
Darwin = "darwin"

// WindowsMaxWorkMem is the maximum work_mem/maintenance_work_mem on Windows for PostgreSQL <= 17
// PostgreSQL used MAX_KILOBYTES = INT_MAX/1024 when SIZEOF_LONG <= 4
// Windows LLP64 model has sizeof(long)==4 even on 64-bit systems
// This resulted in max value of 2097151 kB (~2GB) on Windows
// Fixed in PostgreSQL 18 by removing SIZEOF_LONG check from MAX_KILOBYTES
// Mailing list: https://www.postgresql.org/message-id/flat/1a01f0-66ec2d80-3b-68487680@27595217
// Related: https://github.com/pgvector/pgvector/issues/667
WindowsMaxWorkMem = 2097151 * bytes.KB
)

// ValidOS validates the Operating System
Expand Down Expand Up @@ -43,6 +52,17 @@ func computeOS(in *input.Input, cfg *category.ExportCfg) (*category.ExportCfg, e

if in.OS == "windows" {
cfg.Storage.EffectiveIOConcurrency = 0

// Windows had 2GB limitation for work_mem and maintenance_work_mem on PG <= 17
// Fixed in PostgreSQL 18: https://www.postgresql.org/message-id/flat/1a01f0-66ec2d80-3b-68487680@27595217
if in.PostgresVersion < 18.0 {
if cfg.Memory.WorkMem > WindowsMaxWorkMem {
cfg.Memory.WorkMem = WindowsMaxWorkMem
}
if cfg.Memory.MaintenanceWorkMem > WindowsMaxWorkMem {
cfg.Memory.MaintenanceWorkMem = WindowsMaxWorkMem
}
}
}

return cfg, nil
Expand Down
Loading