Repository navigation
Expand file tree
/
Copy pathBombardierLoadGenerator.cs
More file actions
191 lines (167 loc) · 7.84 KB
/
Copy pathBombardierLoadGenerator.cs
File metadata and controls
191 lines (167 loc) · 7.84 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
using System.Diagnostics;
using System.Globalization;
using System.Text.Json;
namespace Titanium.Web.Proxy.RpsLoadProbe;
/// <summary>
/// Shells out to <c>bombardier</c> on PATH for origin-saturation calibration.
/// Labeled <c>bombardier</c> in CSV — not used for publishable TWP÷peer ratios.
/// </summary>
internal static class BombardierLoadGenerator
{
public const string GeneratorName = "bombardier";
public static string? ResolveExecutable()
{
var pathEnv = Environment.GetEnvironmentVariable("PATH") ?? string.Empty;
var sep = Path.PathSeparator;
var isWindows = OperatingSystem.IsWindows();
foreach (var dir in pathEnv.Split(sep, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
var candidate = Path.Combine(dir, isWindows ? "bombardier.exe" : "bombardier");
if (File.Exists(candidate))
return candidate;
}
return null;
}
public static bool IsAvailable() => ResolveExecutable() != null;
public static Task WarmupAsync(Uri target, int concurrency, TimeSpan duration, WorkloadOptions workload,
CancellationToken cancellationToken) =>
RunCoreAsync(target, concurrency, duration, collectLatency: false, workload, cancellationToken);
public static Task<LoadResult> RunAsync(Uri target, int concurrency, TimeSpan duration,
WorkloadOptions workload, CancellationToken cancellationToken = default) =>
RunCoreAsync(target, concurrency, duration, collectLatency: true, workload, cancellationToken);
private static async Task<LoadResult> RunCoreAsync(Uri target, int concurrency, TimeSpan duration,
bool collectLatency, WorkloadOptions workload, CancellationToken cancellationToken)
{
var exe = ResolveExecutable()
?? throw new InvalidOperationException(
"bombardier not found on PATH. Install from https://github.com/codesenberg/bombardier/releases");
if (!workload.KeepAlive)
throw new NotSupportedException("bombardier arm requires keep-alive (saturation control is tiny-GET KA).");
if (!string.Equals(workload.Method, "GET", StringComparison.OrdinalIgnoreCase))
throw new NotSupportedException("bombardier arm supports GET only.");
if (workload.RequestBytes > 0 || workload.IsWebSocket || workload.IsGrpc || workload.IsEarlyResponse || workload.IsSlowConsumer)
throw new NotSupportedException("bombardier arm supports tiny GET only.");
var durationSec = Math.Max(1, (int)Math.Ceiling(duration.TotalSeconds));
var psi = new ProcessStartInfo
{
FileName = exe,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
psi.ArgumentList.Add("-c");
psi.ArgumentList.Add(concurrency.ToString(CultureInfo.InvariantCulture));
psi.ArgumentList.Add("-d");
psi.ArgumentList.Add($"{durationSec}s");
if (collectLatency)
psi.ArgumentList.Add("-l");
// result-only + full "json" (not "j") — otherwise stdout starts with "Bombarding..." and JSON parse fails.
psi.ArgumentList.Add("--print");
psi.ArgumentList.Add("result");
psi.ArgumentList.Add("-o");
psi.ArgumentList.Add("json");
psi.ArgumentList.Add("--http1");
if (workload.ExtraHeaders is { Count: > 0 } headers)
{
foreach (var (name, value) in headers)
{
psi.ArgumentList.Add("-H");
psi.ArgumentList.Add($"{name}: {value}");
}
}
psi.ArgumentList.Add(target.AbsoluteUri);
using var process = Process.Start(psi)
?? throw new InvalidOperationException($"Failed to start bombardier: {exe}");
using var waitCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
// bombardier -d should exit; if it sticks, kill so saturation cannot hang for hours.
waitCts.CancelAfter(duration + TimeSpan.FromSeconds(30));
var stdoutTask = process.StandardOutput.ReadToEndAsync(waitCts.Token);
var stderrTask = process.StandardError.ReadToEndAsync(waitCts.Token);
try
{
await process.WaitForExitAsync(waitCts.Token)
.WaitAsync(duration + TimeSpan.FromSeconds(45), cancellationToken);
}
catch (Exception ex) when (ex is TimeoutException or OperationCanceledException)
{
try
{
if (!process.HasExited)
process.Kill(entireProcessTree: true);
}
catch
{
// best-effort
}
throw new TimeoutException(
$"bombardier did not exit within {duration.TotalSeconds:F0}s+grace (possible hang).", ex);
}
var stdout = await stdoutTask;
var stderr = await stderrTask;
if (process.ExitCode != 0 && string.IsNullOrWhiteSpace(stdout))
{
throw new InvalidOperationException(
$"bombardier exited {process.ExitCode}. stderr: {stderr}");
}
return ParseJsonResult(ExtractJsonObject(stdout), concurrency, duration.TotalSeconds);
}
/// <summary>Pull the first top-level JSON object if progress/intro leaked onto stdout.</summary>
internal static string ExtractJsonObject(string stdout)
{
var start = stdout.IndexOf('{');
var end = stdout.LastIndexOf('}');
if (start < 0 || end <= start)
throw new InvalidOperationException(
$"bombardier stdout was not JSON. First 120 chars: {(stdout.Length > 120 ? stdout[..120] : stdout)}");
return stdout[start..(end + 1)];
}
internal static LoadResult ParseJsonResult(string json, int concurrency, double durationSeconds)
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
if (!root.TryGetProperty("result", out var result))
throw new InvalidOperationException("bombardier JSON missing result object.");
var ok = GetLong(result, "req2xx") + GetLong(result, "req1xx") + GetLong(result, "req3xx");
var errors = GetLong(result, "req4xx") + GetLong(result, "req5xx") + GetLong(result, "others");
var total = ok + errors;
var rps = 0.0;
if (result.TryGetProperty("rps", out var rpsObj) && rpsObj.TryGetProperty("mean", out var rpsMean))
rps = rpsMean.GetDouble();
var p50Ms = 0.0;
var p99Ms = 0.0;
var maxMs = 0.0;
if (result.TryGetProperty("latency", out var latency))
{
if (latency.TryGetProperty("max", out var maxNs))
maxMs = NsToMs(maxNs.GetDouble());
if (latency.TryGetProperty("percentiles", out var pct))
{
p50Ms = NsToMs(GetPercentile(pct, "50"));
p99Ms = NsToMs(GetPercentile(pct, "99"));
}
}
var errorRate = total == 0 ? 100.0 : errors * 100.0 / total;
return new LoadResult(
Generator: GeneratorName,
Concurrency: concurrency,
DurationSeconds: durationSeconds,
Ok: ok,
Errors: errors,
Rps: rps,
ErrorRatePercent: errorRate,
P50Ms: p50Ms,
P99Ms: p99Ms,
MaxMs: maxMs,
NegotiatedVersionHint: "1.1");
}
private static double GetPercentile(JsonElement percentiles, string key)
{
if (percentiles.TryGetProperty(key, out var value))
return value.GetDouble();
return 0;
}
private static long GetLong(JsonElement obj, string name) =>
obj.TryGetProperty(name, out var v) ? v.GetInt64() : 0;
private static double NsToMs(double nanoseconds) => nanoseconds / 1_000_000.0;
}