Skip to content

Commit 5a45431

Browse files
committed
Convert the benchmark script to Ruby
1 parent 4b0d96f commit 5a45431

File tree

3 files changed

+319
-215
lines changed

3 files changed

+319
-215
lines changed

.github/workflows/benchmark.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,7 @@ jobs:
272272
set -e # Exit on any error
273273
echo "🏃 Running benchmark suite..."
274274
275-
if ! spec/performance/bench.sh; then
275+
if ! ruby spec/performance/bench.rb; then
276276
echo "❌ ERROR: Benchmark execution failed"
277277
exit 1
278278
fi

spec/performance/bench.rb

Lines changed: 318 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,318 @@
1+
#!/usr/bin/env ruby
2+
# frozen_string_literal: true
3+
4+
require "json"
5+
require "fileutils"
6+
require "net/http"
7+
require "uri"
8+
9+
# Benchmark parameters
10+
BASE_URL = ENV.fetch("BASE_URL", "localhost:3001")
11+
ROUTE = ENV.fetch("ROUTE", "server_side_hello_world_hooks")
12+
TARGET = URI.parse("http://#{BASE_URL}/#{ROUTE}")
13+
# requests per second; if "max" will get maximum number of queries instead of a fixed rate
14+
RATE = ENV.fetch("RATE", "50")
15+
# concurrent connections/virtual users
16+
CONNECTIONS = ENV.fetch("CONNECTIONS", "10").to_i
17+
# maximum connections/virtual users
18+
MAX_CONNECTIONS = ENV.fetch("MAX_CONNECTIONS", CONNECTIONS.to_s).to_i
19+
DURATION_SEC = ENV.fetch("DURATION_SEC", "10").to_f
20+
DURATION = "#{DURATION_SEC}s".freeze
21+
# request timeout (duration string like "60s", "1m", "90s")
22+
REQUEST_TIMEOUT = ENV.fetch("REQUEST_TIMEOUT", "60s")
23+
# Tools to run (comma-separated)
24+
TOOLS = ENV.fetch("TOOLS", "fortio,vegeta,k6").split(",")
25+
26+
OUTDIR = "bench_results"
27+
FORTIO_JSON = "#{OUTDIR}/fortio.json".freeze
28+
FORTIO_TXT = "#{OUTDIR}/fortio.txt".freeze
29+
VEGETA_BIN = "#{OUTDIR}/vegeta.bin".freeze
30+
VEGETA_JSON = "#{OUTDIR}/vegeta.json".freeze
31+
VEGETA_TXT = "#{OUTDIR}/vegeta.txt".freeze
32+
K6_TEST_JS = "#{OUTDIR}/k6_test.js".freeze
33+
K6_SUMMARY_JSON = "#{OUTDIR}/k6_summary.json".freeze
34+
K6_TXT = "#{OUTDIR}/k6.txt".freeze
35+
SUMMARY_TXT = "#{OUTDIR}/summary.txt".freeze
36+
37+
# Validate input parameters
38+
def validate_rate(rate)
39+
return if rate == "max"
40+
41+
return if rate.match?(/^\d+(\.\d+)?$/) && rate.to_f.positive?
42+
43+
raise "RATE must be 'max' or a positive number (got: '#{rate}')"
44+
end
45+
46+
def validate_positive_integer(value, name)
47+
return if value.is_a?(Integer) && value.positive?
48+
49+
raise "#{name} must be a positive integer (got: '#{value}')"
50+
end
51+
52+
def validate_duration(value, name)
53+
return if value.is_a?(Numeric) && value.positive?
54+
55+
raise "#{name} must be a positive number (got: '#{value}')"
56+
end
57+
58+
def validate_timeout(value)
59+
return if value.match?(/^(\d+(\.\d+)?[smh])+$/)
60+
61+
raise "REQUEST_TIMEOUT must be a duration like '60s', '1m', '1.5m' (got: '#{value}')"
62+
end
63+
64+
def parse_json_file(file_path, tool_name)
65+
JSON.parse(File.read(file_path))
66+
rescue Errno::ENOENT
67+
raise "#{tool_name} results file not found: #{file_path}"
68+
rescue JSON::ParserError => e
69+
raise "Failed to parse #{tool_name} JSON: #{e.message}"
70+
rescue StandardError => e
71+
raise "Failed to read #{tool_name} results: #{e.message}"
72+
end
73+
74+
validate_rate(RATE)
75+
validate_positive_integer(CONNECTIONS, "CONNECTIONS")
76+
validate_positive_integer(MAX_CONNECTIONS, "MAX_CONNECTIONS")
77+
validate_duration(DURATION_SEC, "DURATION_SEC")
78+
validate_timeout(REQUEST_TIMEOUT)
79+
80+
raise "MAX_CONNECTIONS (#{MAX_CONNECTIONS}) must be >= CONNECTIONS (#{CONNECTIONS})" if MAX_CONNECTIONS < CONNECTIONS
81+
82+
# Precompute checks for each tool
83+
run_fortio = TOOLS.include?("fortio")
84+
run_vegeta = TOOLS.include?("vegeta")
85+
run_k6 = TOOLS.include?("k6")
86+
87+
# Check required tools are installed
88+
required_tools = TOOLS + %w[column tee]
89+
required_tools.each do |cmd|
90+
raise "required tool '#{cmd}' is not installed" unless system("command -v #{cmd} >/dev/null 2>&1")
91+
end
92+
93+
puts <<~PARAMS
94+
Benchmark parameters:
95+
- RATE: #{RATE}
96+
- DURATION_SEC: #{DURATION_SEC}
97+
- REQUEST_TIMEOUT: #{REQUEST_TIMEOUT}
98+
- CONNECTIONS: #{CONNECTIONS}
99+
- MAX_CONNECTIONS: #{MAX_CONNECTIONS}
100+
- WEB_CONCURRENCY: #{ENV['WEB_CONCURRENCY'] || 'unset'}
101+
- RAILS_MAX_THREADS: #{ENV['RAILS_MAX_THREADS'] || 'unset'}
102+
- RAILS_MIN_THREADS: #{ENV['RAILS_MIN_THREADS'] || 'unset'}
103+
- TOOLS: #{TOOLS.join(', ')}
104+
PARAMS
105+
106+
# Helper method to check if server is responding
107+
def server_responding?(uri)
108+
response = Net::HTTP.get_response(uri)
109+
response.is_a?(Net::HTTPSuccess)
110+
rescue StandardError
111+
false
112+
end
113+
114+
# Wait for the server to be ready
115+
TIMEOUT_SEC = 60
116+
start_time = Time.now
117+
loop do
118+
break if server_responding?(TARGET)
119+
120+
raise "Target #{TARGET} not responding within #{TIMEOUT_SEC}s" if Time.now - start_time > TIMEOUT_SEC
121+
122+
sleep 1
123+
end
124+
125+
# Warm up server
126+
puts "Warming up server with 10 requests..."
127+
10.times do
128+
server_responding?(TARGET)
129+
sleep 0.5
130+
end
131+
puts "Warm-up complete"
132+
133+
FileUtils.mkdir_p(OUTDIR)
134+
135+
# Configure tool-specific arguments
136+
if RATE == "max"
137+
if CONNECTIONS != MAX_CONNECTIONS
138+
raise "For RATE=max, CONNECTIONS must be equal to MAX_CONNECTIONS (got #{CONNECTIONS} and #{MAX_CONNECTIONS})"
139+
end
140+
141+
fortio_args = ["-qps", 0, "-c", CONNECTIONS]
142+
vegeta_args = ["-rate=infinity", "--workers=#{CONNECTIONS}", "--max-workers=#{CONNECTIONS}"]
143+
k6_scenarios = <<~JS.strip
144+
{
145+
max_rate: {
146+
executor: 'constant-vus',
147+
vus: #{CONNECTIONS},
148+
duration: '#{DURATION}'
149+
}
150+
}
151+
JS
152+
else
153+
fortio_args = ["-qps", RATE, "-uniform", "-nocatchup", "-c", CONNECTIONS]
154+
vegeta_args = ["-rate=#{RATE}", "--workers=#{CONNECTIONS}", "--max-workers=#{MAX_CONNECTIONS}"]
155+
k6_scenarios = <<~JS.strip
156+
{
157+
constant_rate: {
158+
executor: 'constant-arrival-rate',
159+
rate: #{RATE},
160+
timeUnit: '1s',
161+
duration: '#{DURATION}',
162+
preAllocatedVUs: #{CONNECTIONS},
163+
maxVUs: #{MAX_CONNECTIONS}
164+
}
165+
}
166+
JS
167+
end
168+
169+
# Run Fortio
170+
if run_fortio
171+
puts "===> Fortio"
172+
# TODO: https://github.com/fortio/fortio/wiki/FAQ#i-want-to-get-the-best-results-what-flags-should-i-pass
173+
fortio_cmd = [
174+
"fortio", "load",
175+
*fortio_args,
176+
"-t", DURATION,
177+
"-timeout", REQUEST_TIMEOUT,
178+
"-json", FORTIO_JSON,
179+
TARGET
180+
].join(" ")
181+
raise "Fortio benchmark failed" unless system("#{fortio_cmd} | tee #{FORTIO_TXT}")
182+
end
183+
184+
# Run Vegeta
185+
if run_vegeta
186+
puts "\n===> Vegeta"
187+
vegeta_cmd = [
188+
"echo", "'GET #{TARGET}'", "|",
189+
"vegeta", "attack",
190+
*vegeta_args,
191+
"-duration=#{DURATION}",
192+
"-timeout=#{REQUEST_TIMEOUT}"
193+
].join(" ")
194+
raise "Vegeta attack failed" unless system("#{vegeta_cmd} | tee #{VEGETA_BIN} | vegeta report | tee #{VEGETA_TXT}")
195+
raise "Vegeta report generation failed" unless system("vegeta report -type=json #{VEGETA_BIN} > #{VEGETA_JSON}")
196+
end
197+
198+
# Run k6
199+
if run_k6
200+
puts "\n===> k6"
201+
k6_script = <<~JS
202+
import http from 'k6/http';
203+
import { check } from 'k6';
204+
205+
export const options = {
206+
scenarios: #{k6_scenarios},
207+
httpReq: {
208+
timeout: '#{REQUEST_TIMEOUT}',
209+
},
210+
};
211+
212+
export default function () {
213+
const response = http.get('#{TARGET}');
214+
check(response, {
215+
'status=200': r => r.status === 200,
216+
// you can add more if needed:
217+
// 'status=500': r => r.status === 500,
218+
});
219+
}
220+
JS
221+
File.write(K6_TEST_JS, k6_script)
222+
k6_command = "k6 run --summary-export=#{K6_SUMMARY_JSON} --summary-trend-stats 'min,avg,med,max,p(90),p(99)'"
223+
raise "k6 benchmark failed" unless system("#{k6_command} #{K6_TEST_JS} | tee #{K6_TXT}")
224+
end
225+
226+
puts "\n===> Parsing results and generating summary"
227+
228+
# Initialize summary file
229+
File.write(SUMMARY_TXT, "Tool\tRPS\tp50(ms)\tp90(ms)\tp99(ms)\tStatus\n")
230+
231+
# Parse Fortio results
232+
if run_fortio
233+
begin
234+
fortio_data = parse_json_file(FORTIO_JSON, "Fortio")
235+
fortio_rps = fortio_data["ActualQPS"]&.round(2) || "missing"
236+
237+
percentiles = fortio_data.dig("DurationHistogram", "Percentiles") || []
238+
p50_data = percentiles.find { |p| p["Percentile"] == 50 }
239+
p90_data = percentiles.find { |p| p["Percentile"] == 90 }
240+
p99_data = percentiles.find { |p| p["Percentile"] == 99 }
241+
242+
raise "Fortio results missing percentile data" unless p50_data && p90_data && p99_data
243+
244+
fortio_p50 = (p50_data["Value"] * 1000).round(2)
245+
fortio_p90 = (p90_data["Value"] * 1000).round(2)
246+
fortio_p99 = (p99_data["Value"] * 1000).round(2)
247+
fortio_status = fortio_data["RetCodes"]&.map { |k, v| "#{k}=#{v}" }&.join(",") || "unknown"
248+
File.open(SUMMARY_TXT, "a") do |f|
249+
f.puts "Fortio\t#{fortio_rps}\t#{fortio_p50}\t#{fortio_p90}\t#{fortio_p99}\t#{fortio_status}"
250+
end
251+
rescue StandardError => e
252+
puts "Error: #{e.message}"
253+
File.open(SUMMARY_TXT, "a") do |f|
254+
f.puts "Fortio\tFAILED\tFAILED\tFAILED\tFAILED\t#{e.message}"
255+
end
256+
end
257+
end
258+
259+
# Parse Vegeta results
260+
if run_vegeta
261+
begin
262+
vegeta_data = parse_json_file(VEGETA_JSON, "Vegeta")
263+
# .throughput is successful_reqs/total_period, .rate is all_requests/attack_period
264+
vegeta_rps = vegeta_data["throughput"]&.round(2) || "missing"
265+
vegeta_p50 = vegeta_data.dig("latencies", "50th")&./(1_000_000.0)&.round(2) || "missing"
266+
vegeta_p90 = vegeta_data.dig("latencies", "90th")&./(1_000_000.0)&.round(2) || "missing"
267+
vegeta_p99 = vegeta_data.dig("latencies", "99th")&./(1_000_000.0)&.round(2) || "missing"
268+
vegeta_status = vegeta_data["status_codes"]&.map { |k, v| "#{k}=#{v}" }&.join(",") || "unknown"
269+
vegeta_line = [
270+
"Vegeta", vegeta_rps, vegeta_p50, vegeta_p90, vegeta_p99, vegeta_status
271+
].join("\t")
272+
File.open(SUMMARY_TXT, "a") do |f|
273+
f.puts vegeta_line
274+
end
275+
rescue StandardError => e
276+
puts "Error: #{e.message}"
277+
File.open(SUMMARY_TXT, "a") do |f|
278+
f.puts "Vegeta\tFAILED\tFAILED\tFAILED\tFAILED\t#{e.message}"
279+
end
280+
end
281+
end
282+
283+
# Parse k6 results
284+
if run_k6
285+
begin
286+
k6_data = parse_json_file(K6_SUMMARY_JSON, "k6")
287+
k6_rps = k6_data.dig("metrics", "iterations", "rate")&.round(2) || "missing"
288+
k6_p50 = k6_data.dig("metrics", "http_req_duration", "med")&.round(2) || "missing"
289+
k6_p90 = k6_data.dig("metrics", "http_req_duration", "p(90)")&.round(2) || "missing"
290+
k6_p99 = k6_data.dig("metrics", "http_req_duration", "p(99)")&.round(2) || "missing"
291+
292+
# Status: compute successful vs failed requests
293+
k6_reqs_total = k6_data.dig("metrics", "http_reqs", "count") || 0
294+
k6_checks = k6_data.dig("root_group", "checks") || {}
295+
# Extract status code from check name (e.g., "status=200" -> "200")
296+
# Handle both "status=XXX" format and other potential formats
297+
k6_status_parts = k6_checks.map do |name, check|
298+
status_label = name.start_with?("status=") ? name.delete_prefix("status=") : name
299+
"#{status_label}=#{check['passes']}"
300+
end
301+
k6_reqs_known_status = k6_checks.values.sum { |check| check["passes"] || 0 }
302+
k6_reqs_other = k6_reqs_total - k6_reqs_known_status
303+
k6_status_parts << "other=#{k6_reqs_other}" if k6_reqs_other.positive?
304+
k6_status = k6_status_parts.empty? ? "missing" : k6_status_parts.join(",")
305+
306+
File.open(SUMMARY_TXT, "a") do |f|
307+
f.puts "k6\t#{k6_rps}\t#{k6_p50}\t#{k6_p90}\t#{k6_p99}\t#{k6_status}"
308+
end
309+
rescue StandardError => e
310+
puts "Error: #{e.message}"
311+
File.open(SUMMARY_TXT, "a") do |f|
312+
f.puts "k6\tFAILED\tFAILED\tFAILED\tFAILED\t#{e.message}"
313+
end
314+
end
315+
end
316+
317+
puts "\nSummary saved to #{SUMMARY_TXT}"
318+
system("column", "-t", "-s", "\t", SUMMARY_TXT)

0 commit comments

Comments
 (0)