param( [string]$PgHost = "localhost", [int]$PgPort = 5432, [string]$PgUser = "postgres", [string]$BenchDb = "pgbench", [int]$Scale = 50, [int]$Duration = 60, [string]$Clients = "1 4 8 16 32", [int]$Jobs = 4, [int]$WarmupSeconds = 15, [bool]$RunReadOnly = $true, [bool]$RunCustom = $false, [string]$CustomSqlFile = "pgbench_custom_workload.sql", [string]$CustomModeName = "custom_sql", [ValidateRange(1, 99)][int]$CustomReadPct = 80, [ValidateRange(1, 100)][int]$CustomHotPct = 90, [ValidateRange(1, 4)][int]$CustomTxnSize = 2, [ValidateRange(100, 100000000)][int]$CustomHotAccounts = 10000, [bool]$Reinit = $false ) $ErrorActionPreference = "Stop" function Require-Command { param([Parameter(Mandatory = $true)][string]$Name) if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) { throw "Missing required command: $Name" } } function Invoke-PsqlScalar { param( [Parameter(Mandatory = $true)][string]$Database, [Parameter(Mandatory = $true)][string]$Sql ) $output = & psql -X -v ON_ERROR_STOP=1 -h $PgHost -p $PgPort -U $PgUser -d $Database -Atc $Sql if ($LASTEXITCODE -ne 0) { throw "psql failed running SQL: $Sql" } return ($output | Select-Object -First 1) } function Invoke-PsqlNonQuery { param( [Parameter(Mandatory = $true)][string]$Database, [Parameter(Mandatory = $true)][string]$Sql ) & psql -X -v ON_ERROR_STOP=1 -h $PgHost -p $PgPort -U $PgUser -d $Database -c $Sql | Out-Null if ($LASTEXITCODE -ne 0) { throw "psql failed running SQL: $Sql" } } function Ensure-BenchmarkDatabase { $exists = Invoke-PsqlScalar -Database "postgres" -Sql "SELECT 1 FROM pg_database WHERE datname = '$BenchDb';" if ($exists -ne "1") { Write-Host "Creating benchmark database: $BenchDb" Invoke-PsqlNonQuery -Database "postgres" -Sql "CREATE DATABASE $BenchDb;" } } function Needs-Init { $hasTable = Invoke-PsqlScalar -Database $BenchDb -Sql "SELECT to_regclass('public.pgbench_accounts') IS NOT NULL;" return $hasTable -ne "t" } function Init-OrReinitData { if ($Reinit -or (Needs-Init)) { Write-Host "Initializing pgbench schema/data (scale=$Scale)" & pgbench -h $PgHost -p $PgPort -U $PgUser -i -s $Scale $BenchDb | Out-Host if ($LASTEXITCODE -ne 0) { throw "pgbench initialization failed" } } else { Write-Host "Using existing pgbench data in $BenchDb" } } function Parse-Tps { param([Parameter(Mandatory = $true)][string]$Text) $matches = [regex]::Matches($Text, "(?m)^tps\s*=\s*([0-9]+(?:\.[0-9]+)?)") if ($matches.Count -gt 0) { return $matches[$matches.Count - 1].Groups[1].Value } return "" } function Parse-AvgLatencyMs { param([Parameter(Mandatory = $true)][string]$Text) $matches = [regex]::Matches($Text, "(?m)^latency average\s*=\s*([0-9]+(?:\.[0-9]+)?)\s*ms") if ($matches.Count -gt 0) { return $matches[$matches.Count - 1].Groups[1].Value } return "" } function Run-Case { param( [Parameter(Mandatory = $true)][string]$Mode, [Parameter(Mandatory = $true)][int]$ClientCount, [AllowEmptyCollection()][string[]]$ExtraArgs = @(), [Parameter(Mandatory = $true)][string]$ResultDir, [Parameter(Mandatory = $true)][string]$SummaryCsv ) $logFile = Join-Path $ResultDir ("{0}_c{1}.txt" -f $Mode, $ClientCount) $txLogPrefix = Join-Path $ResultDir ("{0}_c{1}_txlog" -f $Mode, $ClientCount) Write-Host "Running mode=$Mode, clients=$ClientCount, duration=${Duration}s" # Warm cache and avoid measuring first-run effects. & pgbench -h $PgHost -p $PgPort -U $PgUser -n -M prepared -j $Jobs -c $ClientCount -T $WarmupSeconds @ExtraArgs $BenchDb | Out-Null if ($LASTEXITCODE -ne 0) { throw "Warm-up run failed for mode=$Mode clients=$ClientCount" } $runOutput = & pgbench -h $PgHost -p $PgPort -U $PgUser -n -M prepared -j $Jobs -c $ClientCount -T $Duration -r -l --log-prefix $txLogPrefix @ExtraArgs $BenchDb 2>&1 if ($LASTEXITCODE -ne 0) { throw "Measured run failed for mode=$Mode clients=$ClientCount" } $runOutput | Set-Content -Path $logFile -Encoding UTF8 $text = ($runOutput -join [Environment]::NewLine) $tps = Parse-Tps -Text $text $latencyMs = Parse-AvgLatencyMs -Text $text $percentiles = Get-PercentileLatency -LogPrefix $txLogPrefix "$Mode,$ClientCount,$Duration,$Jobs,$tps,$latencyMs,$($percentiles.P95Ms),$($percentiles.P99Ms),$logFile" | Add-Content -Path $SummaryCsv } function Get-PercentileLatency { param([Parameter(Mandatory = $true)][string]$LogPrefix) $logFiles = Get-ChildItem -Path ($LogPrefix + "*") -File -ErrorAction SilentlyContinue if (-not $logFiles -or $logFiles.Count -eq 0) { return @{ P95Ms = ""; P99Ms = "" } } # Keep memory bounded even for very large transaction logs. $maxSampleSize = 200000 $reservoir = New-Object System.Collections.Generic.List[double] $reservoir.Capacity = $maxSampleSize $random = [System.Random]::new() $seen = 0 foreach ($file in $logFiles) { $reader = [System.IO.File]::OpenText($file.FullName) try { while (($line = $reader.ReadLine()) -ne $null) { if ([string]::IsNullOrWhiteSpace($line) -or $line.StartsWith("#")) { continue } $tokens = $line -split "[\s,]+" | Where-Object { $_ -ne "" } if ($tokens.Count -ge 3 -and $tokens[2] -match "^[0-9]+(?:\.[0-9]+)?$") { $latUs = [double]$tokens[2] $seen++ if ($reservoir.Count -lt $maxSampleSize) { $reservoir.Add($latUs) } else { $idx = $random.Next(0, $seen) if ($idx -lt $maxSampleSize) { $reservoir[$idx] = $latUs } } } } } finally { $reader.Dispose() } } if ($reservoir.Count -eq 0) { return @{ P95Ms = ""; P99Ms = "" } } $sorted = $reservoir | Sort-Object $p95Us = Select-PercentileValue -SortedValues $sorted -Percentile 95 $p99Us = Select-PercentileValue -SortedValues $sorted -Percentile 99 return @{ P95Ms = [math]::Round(($p95Us / 1000.0), 3) P99Ms = [math]::Round(($p99Us / 1000.0), 3) } } function Select-PercentileValue { param( [Parameter(Mandatory = $true)][double[]]$SortedValues, [Parameter(Mandatory = $true)][ValidateRange(1, 99)][int]$Percentile ) $n = $SortedValues.Count $rank = [math]::Ceiling(($Percentile / 100.0) * $n) if ($rank -lt 1) { $rank = 1 } if ($rank -gt $n) { $rank = $n } return $SortedValues[$rank - 1] } function Resolve-CustomSqlPath { if ([System.IO.Path]::IsPathRooted($CustomSqlFile)) { return $CustomSqlFile } return (Join-Path (Get-Location) $CustomSqlFile) } Require-Command -Name "psql" Require-Command -Name "pgbench" $timestamp = Get-Date -Format "yyyyMMdd_HHmmss" $resultDir = Join-Path (Get-Location) ("pgbench_results_{0}" -f $timestamp) $summaryCsv = Join-Path $resultDir "summary.csv" New-Item -Path $resultDir -ItemType Directory -Force | Out-Null "mode,clients,duration_seconds,jobs,tps,latency_ms,p95_ms,p99_ms,details_file" | Set-Content -Path $summaryCsv -Encoding UTF8 Ensure-BenchmarkDatabase Init-OrReinitData $customSqlPath = "" if ($RunCustom) { $customSqlPath = Resolve-CustomSqlPath if (-not (Test-Path -Path $customSqlPath -PathType Leaf)) { throw "Custom SQL file not found: $customSqlPath" } } $clientValues = $Clients -split "\s+" | Where-Object { $_ -match "^[0-9]+$" } if ($clientValues.Count -eq 0) { throw "No valid client values found. Example: -Clients '1 4 8 16 32'" } foreach ($c in $clientValues) { $clientInt = [int]$c Run-Case -Mode "read_write" -ClientCount $clientInt -ExtraArgs @() -ResultDir $resultDir -SummaryCsv $summaryCsv if ($RunReadOnly) { Run-Case -Mode "read_only" -ClientCount $clientInt -ExtraArgs @("-S") -ResultDir $resultDir -SummaryCsv $summaryCsv } if ($RunCustom) { Run-Case -Mode $CustomModeName -ClientCount $clientInt -ExtraArgs @( "-D", "read_pct=$CustomReadPct", "-D", "hot_pct=$CustomHotPct", "-D", "txn_size=$CustomTxnSize", "-D", "hot_accounts=$CustomHotAccounts", "-f", $customSqlPath ) -ResultDir $resultDir -SummaryCsv $summaryCsv } } Write-Host "" Write-Host "Benchmark completed." Write-Host "Summary: $summaryCsv" Write-Host "Raw outputs: $resultDir"