<# .SYNOPSIS Cross-language ReadBulk stress benchmark driver. .DESCRIPTION Launches the bench-read-bulk subcommand of every client CLI (.NET, Go, Rust, Python, Java) concurrently against a running gateway and worker. Each client opens its own session, subscribes to -BulkSize tags so the worker's per-session MxAccessValueCache populates from real OnDataChange events, then hammers ReadBulk in a tight in-process loop for -DurationSeconds with per-call high-resolution latency capture. Each emits a single JSON stats object on stdout; this script collates the five into a comparison table. The gateway and worker are assumed to be running at -Endpoint with the API key in $env:. .PARAMETER Clients Which clients to run. Defaults to all five. .PARAMETER Endpoint gRPC endpoint of the gateway. Default localhost:5120. .PARAMETER ApiKeyEnv Environment variable holding the API key. Default MXGATEWAY_API_KEY. .PARAMETER DurationSeconds Steady-state measurement window per client. .PARAMETER WarmupSeconds Warm-up window per client (calls during this window are discarded). .PARAMETER BulkSize Number of tags per ReadBulk call. .PARAMETER TagStart First machine number per client. Each client uses a contiguous range starting here, so machine ranges do not overlap when -DistinctTags is set. .PARAMETER TagPrefix Tag prefix (machine number is appended as %03d). .PARAMETER TagAttribute Attribute appended to each tag. .PARAMETER DistinctTags When set, each client uses its own slice of tags (clients[i] starts at TagStart + i * BulkSize). When unset (default), all clients hit the same tags to maximise contention on the worker's value cache. .PARAMETER ReportPath Where to persist the combined report. Defaults to artifacts/bench/... #> [CmdletBinding()] param( [string[]]$Clients = @("dotnet", "go", "rust", "python", "java"), [string]$Endpoint = "localhost:5120", [string]$ApiKeyEnv = "MXGATEWAY_API_KEY", [int]$DurationSeconds = 30, [int]$WarmupSeconds = 3, [int]$BulkSize = 6, [int]$TagStart = 1, [string]$TagPrefix = "TestMachine_", [string]$TagAttribute = "TestChangingInt", [int]$TimeoutMs = 1500, [switch]$DistinctTags, [string]$ReportPath ) Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" $repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") $validClients = @("dotnet", "go", "rust", "python", "java") foreach ($c in $Clients) { if ($validClients -notcontains $c) { throw "Unsupported client '$c'. Valid: $($validClients -join ', ')." } } if ([string]::IsNullOrWhiteSpace($ReportPath)) { $timestamp = Get-Date -Format "yyyyMMdd-HHmmss" $ReportPath = Join-Path $repoRoot "artifacts/bench/bench-read-bulk-$timestamp.json" } $reportDir = Split-Path -Parent $ReportPath if (-not (Test-Path $reportDir)) { New-Item -ItemType Directory -Path $reportDir -Force | Out-Null } $apiKeyValue = (Get-Item -Path "Env:$ApiKeyEnv" -ErrorAction SilentlyContinue).Value if ([string]::IsNullOrWhiteSpace($apiKeyValue)) { throw "The API key environment variable '$ApiKeyEnv' is not set. Define it before running the bench." } # Temp dir for per-client stdout/stderr capture + (Java only) a one-shot # wrapper .bat that handles cmd.exe's quoting rules for `gradle --args="..."`. $tmpDir = Join-Path ([System.IO.Path]::GetTempPath()) "mxgw-bench-$([guid]::NewGuid())" New-Item -ItemType Directory -Path $tmpDir -Force | Out-Null function ConvertTo-HttpEndpoint { param([string]$Value) if ($Value -match '^https?://') { return $Value } return "http://$Value" } function ConvertTo-HostEndpoint { param([string]$Value) return ($Value -replace '^https?://', '') } # Build the per-client command array. Each client gets its own tag range when # -DistinctTags is set so the workers race against distinct cache slices. function Get-ClientCommand { param( [string]$Client, [int]$ClientIndex ) $effectiveTagStart = if ($DistinctTags) { $TagStart + ($ClientIndex * $BulkSize) } else { $TagStart } $httpEndpoint = ConvertTo-HttpEndpoint -Value $Endpoint $hostEndpoint = ConvertTo-HostEndpoint -Value $Endpoint $clientName = "mxgw-$Client-bench" # Per-call gRPC timeout must exceed (DurationSeconds + WarmupSeconds + slack) # — otherwise the channel-wide timeout cancels the bench mid-loop. $callTimeoutSeconds = [int]([Math]::Max(60, $DurationSeconds + $WarmupSeconds + 30)) switch ($Client) { "dotnet" { # -c Release matches the rest of the matrix: HotSpot/Tier 1 JIT # closes most of the debug/release gap for .NET on its own, but # Release also disables JIT inline thresholds that hurt Stopwatch- # bracketed measurement noise. Same project must have been built # in Release at least once before this --no-build invocation. $cliArgs = @( "run", "--project", "clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli", "-c", "Release", "--no-build", "--", "bench-read-bulk", "--endpoint", $httpEndpoint, "--api-key-env", $ApiKeyEnv, "--timeout", "${callTimeoutSeconds}s", "--client-name", $clientName, "--duration-seconds", "$DurationSeconds", "--warmup-seconds", "$WarmupSeconds", "--bulk-size", "$BulkSize", "--tag-start", "$effectiveTagStart", "--tag-prefix", $TagPrefix, "--tag-attribute", $TagAttribute, "--timeout-ms", "$TimeoutMs", "--json" ) return [pscustomobject]@{ file = "dotnet"; args = $cliArgs; cwd = $repoRoot } } "go" { $cliArgs = @( "run", "./cmd/mxgw-go", "bench-read-bulk", "-endpoint", $hostEndpoint, "-api-key-env", $ApiKeyEnv, "-plaintext", "-json", "-client-name", $clientName, "-duration-seconds", "$DurationSeconds", "-warmup-seconds", "$WarmupSeconds", "-bulk-size", "$BulkSize", "-tag-start", "$effectiveTagStart", "-tag-prefix", $TagPrefix, "-tag-attribute", $TagAttribute, "-timeout-ms", "$TimeoutMs" ) return [pscustomobject]@{ file = "go"; args = $cliArgs; cwd = (Join-Path $repoRoot "clients/go") } } "rust" { # --release is essential: Rust debug builds disable inlining and # add overflow checks, which costs the bench ~45% of throughput # and ~3x of p99 latency vs release. The other compiled clients # don't have this gap (go run is optimized, dotnet/java run JIT- # optimized after the warmup window). $cliArgs = @( "run", "--release", "--quiet", "-p", "mxgw-cli", "--", "bench-read-bulk", "--endpoint", $httpEndpoint, "--api-key-env", $ApiKeyEnv, "--client-name", $clientName, "--duration-seconds", "$DurationSeconds", "--warmup-seconds", "$WarmupSeconds", "--bulk-size", "$BulkSize", "--tag-start", "$effectiveTagStart", "--tag-prefix", $TagPrefix, "--tag-attribute", $TagAttribute, "--timeout-ms", "$TimeoutMs", "--json" ) return [pscustomobject]@{ file = "cargo"; args = $cliArgs; cwd = (Join-Path $repoRoot "clients/rust") } } "python" { $cliArgs = @( "-m", "mxgateway_cli", "bench-read-bulk", "--endpoint", $hostEndpoint, "--api-key-env", $ApiKeyEnv, "--plaintext", "--client-name", $clientName, "--duration-seconds", "$DurationSeconds", "--warmup-seconds", "$WarmupSeconds", "--bulk-size", "$BulkSize", "--tag-start", "$effectiveTagStart", "--tag-prefix", $TagPrefix, "--tag-attribute", $TagAttribute, "--timeout-ms", "$TimeoutMs", "--json" ) $python = 'C:\Users\dohertj2\AppData\Local\Programs\Python\Python312\python.exe' return [pscustomobject]@{ file = $python; args = $cliArgs; cwd = (Join-Path $repoRoot "clients/python"); pythonpath = (Join-Path $repoRoot "clients/python/src") } } "java" { $inner = @( "bench-read-bulk", "--endpoint", $hostEndpoint, "--api-key-env", $ApiKeyEnv, "--plaintext", "--json", "--client-name", $clientName, "--duration-seconds", "$DurationSeconds", "--warmup-seconds", "$WarmupSeconds", "--bulk-size", "$BulkSize", "--tag-start", "$effectiveTagStart", "--tag-prefix", $TagPrefix, "--tag-attribute", $TagAttribute, "--timeout-ms", "$TimeoutMs" ) $gradle = (Get-Command "gradle.bat", "gradle.cmd", "gradle.exe", "gradle" -ErrorAction SilentlyContinue | Select-Object -First 1) if ($null -eq $gradle) { throw "gradle not on PATH; required for the Java bench." } # Start-Process with ArgumentList mangles the `--args="..."` quoting # cmd.exe needs to keep the whole bench-args expression as a single # gradle argument. Workaround: write a one-shot .bat that contains # the literal gradle command line and invoke that batch via cmd. $batPath = Join-Path $tmpDir "java-bench.bat" $batContent = '@echo off' + "`r`n" + '"' + $gradle.Source + '" --quiet :mxgateway-cli:run "--args=' + ($inner -join ' ') + '"' + "`r`n" Set-Content -Path $batPath -Value $batContent -Encoding ASCII return [pscustomobject]@{ file = "cmd.exe"; args = @("/c", $batPath); cwd = (Join-Path $repoRoot "clients/java") } } } } # Start one detached process per client and wait for all. Stdout (the JSON # stats line) is captured to a per-client tmp file; stderr is captured too in # case a bench crashed. $jobs = @() Write-Host "Launching $($Clients.Count) concurrent benches against $Endpoint (duration=$($DurationSeconds)s, warmup=$($WarmupSeconds)s, bulkSize=$BulkSize, distinctTags=$([bool]$DistinctTags))" for ($i = 0; $i -lt $Clients.Count; $i++) { $client = $Clients[$i] $cmd = Get-ClientCommand -Client $client -ClientIndex $i $stdoutPath = Join-Path $tmpDir "$client.out" $stderrPath = Join-Path $tmpDir "$client.err" $startArgs = @{ FilePath = $cmd.file ArgumentList = $cmd.args WorkingDirectory = $cmd.cwd RedirectStandardOutput = $stdoutPath RedirectStandardError = $stderrPath NoNewWindow = $true PassThru = $true } if ($cmd.PSObject.Properties['pythonpath']) { # Python needs PYTHONPATH so the editable mxgateway_cli module resolves. $env:PYTHONPATH = $cmd.pythonpath } $process = Start-Process @startArgs $jobs += [pscustomobject]@{ client = $client; process = $process; stdoutPath = $stdoutPath; stderrPath = $stderrPath } Write-Host " [$client] pid=$($process.Id)" } foreach ($job in $jobs) { $job.process.WaitForExit() } # Parse one JSON line per client. The line is typically the last # `{`-prefixed line in stdout (gradle, dotnet run, cargo run can emit log # noise before it). function Get-JsonStats { param([string]$Path) if (-not (Test-Path $Path)) { return $null } $content = Get-Content -Path $Path -Raw if ([string]::IsNullOrWhiteSpace($content)) { return $null } # Scan from the LAST top-level `{` (the bench JSON is the final structured # output line; earlier text may be log noise from `dotnet run` / `cargo # run` / `gradle :run`). Walk forward counting braces to locate the # matching `}` so nested objects like `latencyMs` don't confuse the parser. $startIndex = -1 $depth = 0 for ($i = $content.Length - 1; $i -ge 0; $i--) { $ch = $content[$i] if ($ch -eq '}') { $depth++ } elseif ($ch -eq '{') { $depth-- if ($depth -eq 0) { $startIndex = $i; break } } } if ($startIndex -lt 0) { return $null } $endIndex = -1 $depth = 0 for ($i = $startIndex; $i -lt $content.Length; $i++) { $ch = $content[$i] if ($ch -eq '{') { $depth++ } elseif ($ch -eq '}') { $depth-- if ($depth -eq 0) { $endIndex = $i; break } } } if ($endIndex -lt 0) { return $null } $json = $content.Substring($startIndex, $endIndex - $startIndex + 1) try { return $json | ConvertFrom-Json } catch { return $null } } $results = @() foreach ($job in $jobs) { $stats = Get-JsonStats -Path $job.stdoutPath if ($null -eq $stats) { $stderr = if (Test-Path $job.stderrPath) { (Get-Content -Path $job.stderrPath -Raw) } else { "" } Write-Warning "[$($job.client)] no JSON stats parsed; exit=$($job.process.ExitCode); stderr=$([string]::IsNullOrWhiteSpace($stderr) ? '(empty)' : $stderr.Substring(0, [Math]::Min(300, $stderr.Length)))" $results += [pscustomobject]@{ client = $job.client; exitCode = $job.process.ExitCode; stats = $null; stderr = $stderr } } else { $results += [pscustomobject]@{ client = $job.client; exitCode = $job.process.ExitCode; stats = $stats; stderr = $null } } } # Pretty-print a side-by-side table. $rows = foreach ($r in $results) { if ($null -eq $r.stats) { [pscustomobject]@{ client = $r.client "calls/sec" = "ERR" "total" = "-" "ok" = "-" "fail" = "-" "cached/total" = "-" "p50 ms" = "-" "p95 ms" = "-" "p99 ms" = "-" "max ms" = "-" "mean ms" = "-" } } else { $s = $r.stats [pscustomobject]@{ client = $s.language "calls/sec" = $s.callsPerSecond "total" = $s.totalCalls "ok" = $s.successfulCalls "fail" = $s.failedCalls "cached/total" = "$($s.cachedReadResults)/$($s.totalReadResults)" "p50 ms" = $s.latencyMs.p50 "p95 ms" = $s.latencyMs.p95 "p99 ms" = $s.latencyMs.p99 "max ms" = $s.latencyMs.max "mean ms" = $s.latencyMs.mean } } } $rows | Format-Table -AutoSize | Out-Host $report = [pscustomobject]@{ schemaVersion = 1 endpoint = $Endpoint apiKeyEnv = $ApiKeyEnv durationSeconds = $DurationSeconds warmupSeconds = $WarmupSeconds bulkSize = $BulkSize distinctTags = [bool]$DistinctTags tagPrefix = $TagPrefix tagAttribute = $TagAttribute startedAt = (Get-Date).ToUniversalTime().ToString("o") clients = $results | ForEach-Object { [ordered]@{ client = $_.client exitCode = $_.exitCode stats = $_.stats } } } $report | ConvertTo-Json -Depth 12 | Set-Content -Path $ReportPath -Encoding UTF8 Write-Host "Combined report written to: $ReportPath" Remove-Item -Path $tmpDir -Recurse -Force -ErrorAction SilentlyContinue