diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml index 76062fa..096c242 100644 --- a/.github/workflows/pr-title.yml +++ b/.github/workflows/pr-title.yml @@ -30,7 +30,9 @@ jobs: exit 1 fi - if [[ "$PR_TITLE" =~ ^[a-z]+(\([^)]*\))?!: ]]; then + breaking_pattern='^[a-z]+(\([^)]*\))?!:' + + if [[ "$PR_TITLE" =~ $breaking_pattern ]]; then echo "::warning::This pull request will trigger a major release after merge." { echo "## Major release" diff --git a/pkg/rules/aio.go b/pkg/rules/aio.go index 8b548ab..6eea51e 100644 --- a/pkg/rules/aio.go +++ b/pkg/rules/aio.go @@ -12,9 +12,58 @@ func computeAIO(in *input.Input, cfg *category.ExportCfg) (*category.ExportCfg, // Set default values already from NewStorageCfg // Adjust io_workers based on profile and CPU cores - // Factor per profile - var factor float64 + workerCalculation := calculateIOWorkers(in.TotalCPU, in.Profile, in.DiskType) + cfg.Storage.IOWorkers = workerCalculation.Value + + // Tune io_max_combine_limit and io_max_concurrency based on profile + // Values assume 8KB pages for limits (16 = 128KB, 128 = 1MB) switch in.Profile { + case profile.DW: + // Data Warehouse benefits from larger I/O chunks and higher concurrency + cfg.Storage.IOMaxCombineLimit = 128 // 1MB + cfg.Storage.IOMaxConcurrency = 256 + case profile.OLTP: + // OLTP benefits from concurrency but keep chunks standard + cfg.Storage.IOMaxConcurrency = 128 + } + + // Optionally set io_method based on OS and profile + // For now keep default "worker" + // If Linux and profile is DW or OLTP, consider io_uring + // but we need to be careful about container environments + // cfg.Storage.IOMethod = "worker" + + return cfg, nil +} + +type ioWorkerCalculation struct { + Value int + InitialValue int + HDDAdjusted bool + MinimumApplied bool + LogicalCPUCapApplied bool +} + +func calculateIOWorkers(totalCPU int, workload profile.Profile, diskType string) ioWorkerCalculation { + calculation := ioWorkerCalculation{ + InitialValue: int(math.Ceil(float64(totalCPU) * ioWorkerFactor(workload, diskType))), + HDDAdjusted: diskType == "HDD", + } + calculation.Value = calculation.InitialValue + if calculation.Value < 2 { + calculation.Value = 2 + calculation.MinimumApplied = true + } + if calculation.Value > totalCPU { + calculation.Value = totalCPU + calculation.LogicalCPUCapApplied = true + } + return calculation +} + +func ioWorkerFactor(workload profile.Profile, diskType string) float64 { + var factor float64 + switch workload { case profile.Desktop: factor = 0.1 case profile.Web: @@ -31,42 +80,12 @@ func computeAIO(in *input.Input, cfg *category.ExportCfg) (*category.ExportCfg, // Adjust factor based on disk type (optional) // HDD may benefit from more workers, SSD from fewer - switch in.DiskType { + switch diskType { case "HDD": factor += 0.1 case "SSD", "SAN": // keep factor as is } - // Calculate io_workers - workers := int(math.Ceil(float64(in.TotalCPU) * factor)) - // Ensure at least 2 workers, but keep default 3 as minimum - if workers < 2 { - workers = 2 - } - // Max workers? Not needed, but can limit to total CPU - if workers > in.TotalCPU { - workers = in.TotalCPU - } - cfg.Storage.IOWorkers = workers - - // Tune io_max_combine_limit and io_max_concurrency based on profile - // Values assume 8KB pages for limits (16 = 128KB, 128 = 1MB) - switch in.Profile { - case profile.DW: - // Data Warehouse benefits from larger I/O chunks and higher concurrency - cfg.Storage.IOMaxCombineLimit = 128 // 1MB - cfg.Storage.IOMaxConcurrency = 256 - case profile.OLTP: - // OLTP benefits from concurrency but keep chunks standard - cfg.Storage.IOMaxConcurrency = 128 - } - - // Optionally set io_method based on OS and profile - // For now keep default "worker" - // If Linux and profile is DW or OLTP, consider io_uring - // but we need to be careful about container environments - // cfg.Storage.IOMethod = "worker" - - return cfg, nil -} \ No newline at end of file + return factor +} diff --git a/pkg/rules/compute.go b/pkg/rules/compute.go index a13700d..8a9ce8e 100644 --- a/pkg/rules/compute.go +++ b/pkg/rules/compute.go @@ -2,6 +2,8 @@ package rules import ( "fmt" + "reflect" + "strings" "github.com/pgconfig/api/pkg/category" "github.com/pgconfig/api/pkg/input" @@ -9,36 +11,109 @@ import ( type rule func(*input.Input, *category.ExportCfg) (*category.ExportCfg, error) -var allRules = []rule{ - computeArch, - computeOS, - computeProfile, - computeStorage, - computeAIO, +type ruleID string + +const ( + ruleArchitecture ruleID = "architecture" + ruleOperatingSystem ruleID = "operating system" + ruleProfile ruleID = "profile" + ruleStorage ruleID = "storage" + ruleAsynchronousIO ruleID = "asynchronous I/O" + rulePostgreSQLVersion ruleID = "PostgreSQL Version" +) + +type namedRule struct { + id ruleID + apply rule +} + +type ruleAdjustment struct { + rule ruleID + before string + after string +} + +type parameterSnapshot struct { + raw any + formatted string +} + +var allRules = []namedRule{ + {id: ruleArchitecture, apply: computeArch}, + {id: ruleOperatingSystem, apply: computeOS}, + {id: ruleProfile, apply: computeProfile}, + {id: ruleStorage, apply: computeStorage}, + {id: ruleAsynchronousIO, apply: computeAIO}, // computeVersion can remove values deppending on the version // to be sure that it will not break other rules, leave it at // the end. - computeVersion, + {id: rulePostgreSQLVersion, apply: computeVersion}, } // Compute evaluate all parameters func Compute(in input.Input) (*category.ExportCfg, error) { + out, _, err := computeWithAdjustments(in) + return out, err +} + +// computeWithAdjustments is the single calculation pipeline shared by legacy +// consumers and the provenance-aware tuning operation. +func computeWithAdjustments(in input.Input) (*category.ExportCfg, map[string][]ruleAdjustment, error) { var ( out *category.ExportCfg err error ) out = category.NewExportCfg(in) + adjustments := make(map[string][]ruleAdjustment) - for _, rule := range allRules { + for _, currentRule := range allRules { + before := parameterValues(out, in.PostgresVersion) - out, err = rule(&in, out) + out, err = currentRule.apply(&in, out) if err != nil { - return nil, fmt.Errorf("could not process rule: %w", err) + return nil, nil, fmt.Errorf("could not process rule: %w", err) + } + after := parameterValues(out, in.PostgresVersion) + for name, afterValue := range after { + beforeValue, existed := before[name] + if existed && !reflect.DeepEqual(beforeValue.raw, afterValue.raw) { + adjustments[name] = append(adjustments[name], ruleAdjustment{ + rule: currentRule.id, before: beforeValue.formatted, after: afterValue.formatted, + }) + } + } + } + + return out, adjustments, nil +} + +func parameterValues(cfg *category.ExportCfg, postgresVersion float32) map[string]parameterSnapshot { + formattedValues := make(map[string]string) + for _, group := range cfg.ToSlice(postgresVersion, false, "") { + for _, parameter := range group.Parameters { + formattedValues[parameter.Name] = parameter.Value } } - return out, nil + values := make(map[string]parameterSnapshot) + categories := reflect.ValueOf(cfg).Elem() + for categoryIndex := 0; categoryIndex < categories.NumField(); categoryIndex++ { + categoryValue := categories.Field(categoryIndex) + if categoryValue.IsNil() { + continue + } + parameters := categoryValue.Elem() + parameterType := parameters.Type() + for parameterIndex := 0; parameterIndex < parameters.NumField(); parameterIndex++ { + name := strings.Split(parameterType.Field(parameterIndex).Tag.Get("json"), ",")[0] + values[name] = parameterSnapshot{ + raw: parameters.Field(parameterIndex).Interface(), + formatted: formattedValues[name], + } + } + } + return values } diff --git a/pkg/rules/tuning.go b/pkg/rules/tuning.go new file mode 100644 index 0000000..fdf9cec --- /dev/null +++ b/pkg/rules/tuning.go @@ -0,0 +1,271 @@ +package rules + +import ( + "fmt" + "strconv" + "strings" + + "github.com/pgconfig/api/pkg/category" + "github.com/pgconfig/api/pkg/input" + "github.com/pgconfig/api/pkg/input/bytes" + "github.com/pgconfig/api/pkg/input/profile" + "github.com/pgconfig/api/pkg/version" +) + +// TuningRequest is the canonical, explicit input to the tuning operation. +type TuningRequest struct { + OS string `json:"os"` + Arch string `json:"arch"` + TotalRAM bytes.Byte `json:"total_ram"` + Profile profile.Profile `json:"profile"` + DiskType string `json:"disk_type"` + MaxConnections int `json:"max_connections"` + TotalCPU int `json:"total_cpu"` + PostgreSQLVersion string `json:"postgres_version"` +} + +// TuningRecommendation describes a final PostgreSQL setting and why it has +// that value. +type TuningRecommendation struct { + Value string `json:"value"` + Reason string `json:"reason"` +} + +// TuningResult is the provenance-aware result returned by Tune. +type TuningResult struct { + Request TuningRequest `json:"request"` + Recommendations map[string]TuningRecommendation `json:"recommendations"` + ApplicationVersion string `json:"application_version"` + legacy *category.ExportCfg +} + +// NewTuningRequest converts a legacy input into the canonical request model. +func NewTuningRequest(in input.Input) TuningRequest { + return TuningRequest{ + OS: in.OS, + Arch: in.Arch, + TotalRAM: in.TotalRAM, + Profile: in.Profile, + DiskType: in.DiskType, + MaxConnections: in.MaxConnections, + TotalCPU: in.TotalCPU, + PostgreSQLVersion: strconv.FormatFloat(float64(in.PostgresVersion), 'f', -1, 32), + } +} + +func (r TuningRequest) legacyInput(rulesVersion float32) input.Input { + return input.Input{ + OS: r.OS, + Arch: r.Arch, + TotalRAM: r.TotalRAM, + Profile: r.Profile, + DiskType: r.DiskType, + MaxConnections: r.MaxConnections, + TotalCPU: r.TotalCPU, + PostgresVersion: rulesVersion, + } +} + +// Tune produces rich recommendations through the shared tuning pipeline. +func Tune(request TuningRequest) (*TuningResult, error) { + normalized := normalizeRequest(request) + rulesVersion, err := parseRulesVersion(normalized.PostgreSQLVersion) + if err != nil { + return nil, err + } + legacy, adjustments, err := computeWithAdjustments(normalized.legacyInput(rulesVersion)) + if err != nil { + return nil, err + } + + recommendations := make(map[string]TuningRecommendation) + for _, group := range legacy.ToSlice(rulesVersion, false, "") { + for _, parameter := range group.Parameters { + if parameter.Name == "listen_addresses" { + continue + } + recommendations[parameter.Name] = TuningRecommendation{ + Value: parameter.Value, + Reason: recommendationReason(parameter.Name, parameter.Value, normalized, adjustments[parameter.Name]), + } + } + } + + return &TuningResult{ + Request: normalized, + Recommendations: recommendations, + ApplicationVersion: version.Pretty(), + legacy: legacy, + }, nil +} + +// CompatibilityProjection returns the legacy category model used by REST v1 +// and the CLI while those consumers are migrated. +func (r *TuningResult) CompatibilityProjection() *category.ExportCfg { + return r.legacy +} + +func normalizeRequest(request TuningRequest) TuningRequest { + request.OS = strings.ToLower(strings.TrimSpace(request.OS)) + request.Arch = strings.ToLower(strings.TrimSpace(request.Arch)) + switch request.Arch { + case "i686": + request.Arch = "386" + case "x86-64": + request.Arch = "amd64" + } + request.DiskType = strings.ToUpper(strings.TrimSpace(request.DiskType)) + request.PostgreSQLVersion = strings.TrimSpace(request.PostgreSQLVersion) + return request +} + +func parseRulesVersion(postgresVersion string) (float32, error) { + parts := strings.Split(strings.TrimSpace(postgresVersion), ".") + major, err := strconv.Atoi(parts[0]) + if err != nil || major < 1 { + return 0, fmt.Errorf("invalid PostgreSQL Version %q", postgresVersion) + } + if major >= 10 { + return float32(major), nil + } + if len(parts) < 2 { + return 0, fmt.Errorf("invalid PostgreSQL Version %q", postgresVersion) + } + minor, err := strconv.Atoi(parts[1]) + if err != nil { + return 0, fmt.Errorf("invalid PostgreSQL Version %q", postgresVersion) + } + parsed, err := strconv.ParseFloat(fmt.Sprintf("%d.%d", major, minor), 32) + if err != nil { + return 0, fmt.Errorf("invalid PostgreSQL Version %q", postgresVersion) + } + return float32(parsed), nil +} + +func recommendationReason(name, value string, request TuningRequest, adjustments []ruleAdjustment) string { + switch name { + case "shared_buffers": + return sharedBuffersReason(value, request, adjustments) + case "work_mem": + if reason, capped := memoryCapReason(value, adjustments); capped { + return reason + } + return fmt.Sprintf("Set to %s from available memory for the %s profile and %d connections.", value, request.Profile, request.MaxConnections) + case "maintenance_work_mem": + if reason, capped := memoryCapReason(value, adjustments); capped { + return reason + } + return fmt.Sprintf("Set to %s as 5%% of memory available to the %s profile.", value, request.Profile) + case "effective_cache_size": + return fmt.Sprintf("Set to %s from memory available to the PostgreSQL and operating-system caches.", value) + case "max_connections": + return fmt.Sprintf("Set to %s to match the requested connection limit.", value) + case "random_page_cost": + return fmt.Sprintf("Set to %s for %s storage and the %s workload profile.", value, request.DiskType, request.Profile) + case "effective_io_concurrency": + if request.OS == Windows { + return fmt.Sprintf("Set to %s for the requested %s storage; the storage policy replaces the initial Windows compatibility value.", value, request.DiskType) + } + return fmt.Sprintf("Set to %s for the requested %s storage.", value, request.DiskType) + case "maintenance_io_concurrency": + return fmt.Sprintf("Set to %s for the requested %s storage.", value, request.DiskType) + case "io_workers": + return ioWorkersReason(value, request) + case "io_max_combine_limit", "io_max_concurrency": + return fmt.Sprintf("Set to %s for the %s workload profile.", value, request.Profile) + case "max_worker_processes", "max_parallel_workers": + if request.TotalCPU < 8 { + return fmt.Sprintf("Set to %s by applying the minimum of 8 to %d logical CPUs.", value, request.TotalCPU) + } + return fmt.Sprintf("Set to %s to match the %d logical CPUs.", value, request.TotalCPU) + case "max_parallel_workers_per_gather": + if request.Profile != profile.DW { + return fmt.Sprintf("Set to %s as the parallel-query default for the %s workload profile.", value, request.Profile) + } + halfCPU := request.TotalCPU / 2 + if halfCPU < 2 { + return fmt.Sprintf("Set to %s from half of %d logical CPUs, raised to the minimum of 2 for the DW workload profile.", value, request.TotalCPU) + } + return fmt.Sprintf("Set to %s from half of %d logical CPUs for the DW workload profile.", value, request.TotalCPU) + case "min_wal_size", "max_wal_size": + return fmt.Sprintf("Set to %s for the %s workload profile.", value, request.Profile) + case "wal_buffers": + if request.Profile == profile.DW { + return fmt.Sprintf("Set to %s for write-heavy DW workloads.", value) + } + if request.Profile == profile.OLTP && value == "32MB" { + return fmt.Sprintf("Set to %s for OLTP because derived shared_buffers exceeds 8GB.", value) + } + return fmt.Sprintf("Set to %s to use automatic PostgreSQL tuning for the %s workload profile.", value, request.Profile) + case "checkpoint_completion_target": + return fmt.Sprintf("Set to %s to spread checkpoint I/O across most of the checkpoint interval.", value) + case "checkpoint_segments": + return fmt.Sprintf("Set to %s as the legacy checkpoint segment count for PostgreSQL releases before 9.5.", value) + case "io_method": + return fmt.Sprintf("Set to %s to use worker-based asynchronous I/O.", value) + case "file_copy_method": + return fmt.Sprintf("Set to %s as the copy method for file operations.", value) + default: + return fmt.Sprintf("Set to %s by the tuning policy for the %s workload profile.", value, request.Profile) + } +} + +func sharedBuffersReason(value string, request TuningRequest, adjustments []ruleAdjustment) string { + reasons := make([]string, 0, len(adjustments)) + for _, adjustment := range adjustments { + switch adjustment.rule { + case ruleArchitecture: + reasons = append(reasons, capAdjustmentReason(adjustment, "for the 32-bit architecture")) + case ruleOperatingSystem: + reasons = append(reasons, capAdjustmentReason(adjustment, "for PostgreSQL 9.6 or earlier")) + case ruleProfile: + reasons = append(reasons, fmt.Sprintf("Adjusted from %s to %s by the DESKTOP profile", adjustment.before, adjustment.after)) + case rulePostgreSQLVersion: + reasons = append(reasons, capAdjustmentReason(adjustment, "for older PostgreSQL performance")) + } + } + if len(reasons) > 0 { + return fmt.Sprintf("Set to %s. %s.", value, strings.Join(reasons, " Then ")) + } + return fmt.Sprintf("Set to %s from the memory share for the %s profile.", value, request.Profile) +} + +func memoryCapReason(value string, adjustments []ruleAdjustment) (string, bool) { + reasons := make([]string, 0, len(adjustments)) + for _, adjustment := range adjustments { + switch adjustment.rule { + case ruleArchitecture: + reasons = append(reasons, capAdjustmentReason(adjustment, "for the 32-bit architecture")) + case ruleOperatingSystem: + reasons = append(reasons, capAdjustmentReason(adjustment, "by the PostgreSQL pre-18 Windows limit")) + } + } + if len(reasons) > 0 { + return fmt.Sprintf("Set to %s. %s.", value, strings.Join(reasons, " Then ")), true + } + return "", false +} + +func capAdjustmentReason(adjustment ruleAdjustment, context string) string { + if adjustment.before == adjustment.after { + return fmt.Sprintf("Capped at %s %s", adjustment.after, context) + } + return fmt.Sprintf("Capped from %s to %s %s", adjustment.before, adjustment.after, context) +} + +func ioWorkersReason(value string, request TuningRequest) string { + calculation := calculateIOWorkers(request.TotalCPU, request.Profile, request.DiskType) + reason := fmt.Sprintf("Set to %s from %d logical CPUs and the %s profile", value, request.TotalCPU, request.Profile) + if calculation.HDDAdjusted { + reason += " with the HDD storage adjustment" + } + reason += "." + + if calculation.MinimumApplied { + reason += fmt.Sprintf(" The initial %d-worker calculation was raised to the minimum of 2.", calculation.InitialValue) + } + if calculation.LogicalCPUCapApplied { + reason += fmt.Sprintf(" It was then capped at %d to avoid exceeding the logical CPU count.", request.TotalCPU) + } + return reason +} diff --git a/pkg/rules/tuning_test.go b/pkg/rules/tuning_test.go new file mode 100644 index 0000000..2d435a3 --- /dev/null +++ b/pkg/rules/tuning_test.go @@ -0,0 +1,387 @@ +package rules + +import ( + "encoding/json" + "reflect" + "strings" + "testing" + + "github.com/pgconfig/api/pkg/input" + "github.com/pgconfig/api/pkg/input/bytes" + "github.com/pgconfig/api/pkg/input/profile" +) + +func TestTuneReturnsCanonicalRequestAndRecommendations(t *testing.T) { + request := TuningRequest{ + OS: " Linux ", + Arch: "AMD64", + TotalRAM: 32 * bytes.GB, + Profile: profile.OLTP, + DiskType: "ssd", + MaxConnections: 100, + TotalCPU: 16, + PostgreSQLVersion: "18.4", + } + + result, err := Tune(request) + if err != nil { + t.Fatal(err) + } + + if result.Request.OS != "linux" || result.Request.Arch != "amd64" || result.Request.DiskType != "SSD" { + t.Fatalf("request was not normalized: %+v", result.Request) + } + if result.Request.PostgreSQLVersion != "18.4" { + t.Fatalf("complete PostgreSQL Version was not preserved: %+v", result.Request) + } + if len(result.Recommendations) == 0 { + t.Fatal("expected recommendations") + } + for name, recommendation := range result.Recommendations { + if recommendation.Value == "" { + t.Errorf("%s has no formatted value", name) + } + if !strings.Contains(recommendation.Reason, recommendation.Value) { + t.Errorf("%s reason does not describe final value %q: %q", name, recommendation.Value, recommendation.Reason) + } + } +} + +func TestTuneReportsLaterCaps(t *testing.T) { + request := validTuningRequest() + request.OS = "windows" + request.TotalRAM = 1 * bytes.TB + request.Profile = profile.OLTP + request.MaxConnections = 1 + request.TotalCPU = 16 + request.PostgreSQLVersion = "17.10" + result, err := Tune(request) + if err != nil { + t.Fatal(err) + } + + for _, name := range []string{"work_mem", "maintenance_work_mem"} { + recommendation := result.Recommendations[name] + if !strings.Contains(recommendation.Reason, "Capped") || !strings.Contains(recommendation.Reason, recommendation.Value) { + t.Errorf("%s does not explain its cap: %+v", name, recommendation) + } + } +} + +func TestTuneDoesNotReportCapsForNaturallyEqualValues(t *testing.T) { + tests := []struct { + name string + request TuningRequest + parameter string + value string + }{ + { + name: "work_mem naturally equals architecture limit", + request: func() TuningRequest { + r := validTuningRequest() + r.Arch = "386" + r.MaxConnections = 1 + return r + }(), + parameter: "work_mem", value: "4GB", + }, + { + name: "shared_buffers naturally equals architecture limit", + request: func() TuningRequest { + r := validTuningRequest() + r.Arch = "386" + return r + }(), + parameter: "shared_buffers", value: "4GB", + }, + { + name: "shared_buffers naturally equals old version limit", + request: func() TuningRequest { + r := validTuningRequest() + r.TotalRAM = 2 * bytes.GB + r.PostgreSQLVersion = "9.6.24" + return r + }(), + parameter: "shared_buffers", value: "512MB", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := Tune(tt.request) + if err != nil { + t.Fatal(err) + } + recommendation := result.Recommendations[tt.parameter] + if recommendation.Value != tt.value { + t.Fatalf("%s = %q, want %q", tt.parameter, recommendation.Value, tt.value) + } + if strings.Contains(strings.ToLower(recommendation.Reason), "capped") { + t.Fatalf("naturally equal value incorrectly reported as capped: %+v", recommendation) + } + }) + } +} + +func TestTuneReportsCapWhenRawValuesRenderEqually(t *testing.T) { + request := validTuningRequest() + request.Arch = "386" + request.TotalRAM = 16*bytes.GB + bytes.MB + + result, err := Tune(request) + if err != nil { + t.Fatal(err) + } + recommendation := result.Recommendations["shared_buffers"] + if recommendation.Value != "4GB" { + t.Fatalf("shared_buffers = %q, want %q", recommendation.Value, "4GB") + } + if !strings.Contains(recommendation.Reason, "Capped") { + t.Fatalf("raw-value cap was omitted because both values render as 4GB: %+v", recommendation) + } +} + +func TestTuneExplainsStorageOverrideOnWindows(t *testing.T) { + request := validTuningRequest() + request.OS = "windows" + result, err := Tune(request) + if err != nil { + t.Fatal(err) + } + + recommendation := result.Recommendations["effective_io_concurrency"] + if recommendation.Value != "200" { + t.Fatalf("effective_io_concurrency = %q, want %q", recommendation.Value, "200") + } + if !strings.Contains(recommendation.Reason, "replaces the initial Windows compatibility value") { + t.Fatalf("reason does not explain the later storage adjustment: %q", recommendation.Reason) + } +} + +func TestTuneExplainsIOWorkerAdjustments(t *testing.T) { + tests := []struct { + name string + totalCPU int + diskType string + contains []string + notContain string + }{ + { + name: "SSD has no storage adjustment", totalCPU: 8, diskType: "SSD", + notContain: "storage adjustment", + }, + { + name: "HDD applies its worker adjustment", totalCPU: 8, diskType: "HDD", + contains: []string{"HDD storage adjustment"}, + }, + { + name: "one CPU applies minimum then CPU cap", totalCPU: 1, diskType: "SSD", + contains: []string{"minimum of 2", "capped at 1"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + request := validTuningRequest() + request.DiskType = tt.diskType + request.TotalCPU = tt.totalCPU + result, err := Tune(request) + if err != nil { + t.Fatal(err) + } + + reason := result.Recommendations["io_workers"].Reason + for _, expected := range tt.contains { + if !strings.Contains(reason, expected) { + t.Errorf("reason %q does not contain %q", reason, expected) + } + } + if tt.notContain != "" && strings.Contains(reason, tt.notContain) { + t.Errorf("reason %q unexpectedly contains %q", reason, tt.notContain) + } + }) + } +} + +func TestTuneExplainsMaintenanceWorkMemIndependentlyOfConnections(t *testing.T) { + result, err := Tune(validTuningRequest()) + if err != nil { + t.Fatal(err) + } + + reason := result.Recommendations["maintenance_work_mem"].Reason + if strings.Contains(reason, "connections") { + t.Fatalf("maintenance_work_mem reason incorrectly depends on connections: %q", reason) + } + if !strings.Contains(reason, "5%") { + t.Fatalf("maintenance_work_mem reason does not explain its memory share: %q", reason) + } +} + +func TestTuneExplainsWorkerFloors(t *testing.T) { + request := validTuningRequest() + request.Profile = profile.DW + request.TotalCPU = 2 + result, err := Tune(request) + if err != nil { + t.Fatal(err) + } + + for _, name := range []string{"max_worker_processes", "max_parallel_workers"} { + recommendation := result.Recommendations[name] + if recommendation.Value != "8" || !strings.Contains(recommendation.Reason, "minimum of 8") { + t.Errorf("%s does not explain its floor: %+v", name, recommendation) + } + } + gather := result.Recommendations["max_parallel_workers_per_gather"] + if gather.Value != "2" || !strings.Contains(gather.Reason, "half of 2 logical CPUs") || !strings.Contains(gather.Reason, "minimum of 2") { + t.Errorf("max_parallel_workers_per_gather does not explain its calculation: %+v", gather) + } +} + +func TestTuneExplainsWALBufferConditions(t *testing.T) { + tests := []struct { + name string + totalRAM bytes.Byte + value string + reason string + }{ + {name: "large OLTP shared buffers", totalRAM: 40 * bytes.GB, value: "32MB", reason: "derived shared_buffers exceeds 8GB"}, + {name: "small OLTP shared buffers", totalRAM: 16 * bytes.GB, value: "-1", reason: "automatic PostgreSQL tuning"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + request := validTuningRequest() + request.TotalRAM = tt.totalRAM + request.Profile = profile.OLTP + result, err := Tune(request) + if err != nil { + t.Fatal(err) + } + + recommendation := result.Recommendations["wal_buffers"] + if recommendation.Value != tt.value || !strings.Contains(recommendation.Reason, tt.reason) { + t.Fatalf("wal_buffers provenance = %+v, want value %q and reason containing %q", recommendation, tt.value, tt.reason) + } + }) + } +} + +func TestTuneExplainsPolicyConstants(t *testing.T) { + request := validTuningRequest() + result, err := Tune(request) + if err != nil { + t.Fatal(err) + } + + expectedReasons := map[string]string{ + "checkpoint_completion_target": "spread checkpoint I/O", + "io_method": "worker-based asynchronous I/O", + "file_copy_method": "copy method for file operations", + } + for name, expected := range expectedReasons { + reason := result.Recommendations[name].Reason + if !strings.Contains(reason, expected) { + t.Errorf("%s reason %q does not explain policy constant %q", name, reason, expected) + } + } + + request.PostgreSQLVersion = "9.4.26" + legacyResult, err := Tune(request) + if err != nil { + t.Fatal(err) + } + checkpointSegments := legacyResult.Recommendations["checkpoint_segments"] + if !strings.Contains(checkpointSegments.Reason, "legacy checkpoint segment count") { + t.Errorf("checkpoint_segments reason does not explain the compatibility policy: %+v", checkpointSegments) + } +} + +func TestTuneOmitsListenAddressesFromRichRecommendations(t *testing.T) { + in := validLegacyInput() + + result, err := Tune(NewTuningRequest(in)) + if err != nil { + t.Fatal(err) + } + if _, exists := result.Recommendations["listen_addresses"]; exists { + t.Fatal("rich recommendations must omit listen_addresses") + } + + legacyParameters := result.CompatibilityProjection().ToSlice(in.PostgresVersion, false, "") + for _, group := range legacyParameters { + for _, parameter := range group.Parameters { + if parameter.Name == "listen_addresses" && parameter.Value == "*" { + return + } + } + } + t.Fatal("compatibility projection must retain listen_addresses") +} + +func TestTuneNormalizesPostgreSQLVersionWhitespace(t *testing.T) { + request := NewTuningRequest(validLegacyInput()) + request.PostgreSQLVersion = " 18.4 " + + result, err := Tune(request) + if err != nil { + t.Fatal(err) + } + if result.Request.PostgreSQLVersion != "18.4" { + t.Fatalf("PostgreSQL Version = %q, want %q", result.Request.PostgreSQLVersion, "18.4") + } +} + +func TestTuneIsDeterministic(t *testing.T) { + request := NewTuningRequest(validLegacyInput()) + + first, err := Tune(request) + if err != nil { + t.Fatal(err) + } + second, err := Tune(request) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(first, second) { + t.Fatalf("repeated tuning differs:\n%+v\n%+v", first, second) + } + + firstJSON, _ := json.Marshal(first) + secondJSON, _ := json.Marshal(second) + if string(firstJSON) != string(secondJSON) { + t.Fatalf("serialized results differ:\n%s\n%s", firstJSON, secondJSON) + } +} + +func TestCompatibilityProjectionMatchesCompute(t *testing.T) { + in := validLegacyInput() + legacy, err := Compute(in) + if err != nil { + t.Fatal(err) + } + result, err := Tune(NewTuningRequest(in)) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(legacy, result.CompatibilityProjection()) { + t.Fatal("compatibility projection changed the legacy calculation") + } +} + +func validTuningRequest() TuningRequest { + return TuningRequest{ + OS: "linux", Arch: "amd64", TotalRAM: 16 * bytes.GB, + Profile: profile.Web, DiskType: "SSD", MaxConnections: 100, + TotalCPU: 8, PostgreSQLVersion: "18.4", + } +} + +func validLegacyInput() input.Input { + return input.Input{ + OS: "linux", Arch: "amd64", TotalRAM: 16 * bytes.GB, + Profile: profile.Web, DiskType: "SSD", MaxConnections: 100, + TotalCPU: 8, PostgresVersion: 18, + } +}