refactor: replace docker benchmark with native ps
This commit is contained in:
+2
-1
@@ -7,4 +7,5 @@
|
||||
/sqlite/
|
||||
.env.production
|
||||
|
||||
resource_usage.*
|
||||
*.log
|
||||
bit
|
||||
|
||||
-257
@@ -1,257 +0,0 @@
|
||||
#!/usr/bin/env crystal
|
||||
|
||||
require "http/client"
|
||||
require "json"
|
||||
require "file_utils"
|
||||
|
||||
# Configuration variables
|
||||
SERVER_URL = "http://localhost:4000"
|
||||
API_URL = "#{SERVER_URL}/api/links"
|
||||
API_KEY = "secure_api_key_1"
|
||||
NUMBER_OF_REQUESTS = 100000
|
||||
|
||||
CONTAINER_NAME = "bit"
|
||||
STATS_FILE = "resource_usage.txt"
|
||||
|
||||
class ResourceMonitor
|
||||
def initialize(@container_name : String)
|
||||
@running = false
|
||||
@stats = [] of {timestamp: Time, cpu: Float64, memory: Float64}
|
||||
end
|
||||
|
||||
def start
|
||||
@running = true
|
||||
@stats.clear
|
||||
|
||||
# Initialize stats file with header
|
||||
File.write(STATS_FILE, "Timestamp\tCPU(%)\tMemory(MiB)\n")
|
||||
|
||||
spawn do
|
||||
while @running
|
||||
if stat = capture_stats
|
||||
# Append each measurement directly to the file
|
||||
File.open(STATS_FILE, "a") do |file|
|
||||
file.puts "#{stat[:timestamp].to_unix}\t#{stat[:cpu]}\t#{stat[:memory]}"
|
||||
end
|
||||
@stats << stat
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def stop
|
||||
@running = false
|
||||
end
|
||||
|
||||
def avg_stats
|
||||
return {cpu: 0.0, memory: 0.0} if @stats.empty?
|
||||
|
||||
total_cpu = 0.0
|
||||
total_memory = 0.0
|
||||
@stats.each do |stat|
|
||||
total_cpu += stat[:cpu]
|
||||
total_memory += stat[:memory]
|
||||
end
|
||||
|
||||
{
|
||||
cpu: total_cpu / @stats.size,
|
||||
memory: total_memory / @stats.size
|
||||
}
|
||||
end
|
||||
|
||||
private def capture_stats
|
||||
output = IO::Memory.new
|
||||
process = Process.run(
|
||||
"docker", ["stats", "--no-stream", "--format", "{{.CPUPerc}},{{.MemUsage}}", @container_name],
|
||||
output: output
|
||||
)
|
||||
|
||||
if process.success?
|
||||
line = output.to_s.strip
|
||||
parts = line.split(",")
|
||||
if parts.size == 2
|
||||
cpu_part = parts[0].gsub("%", "").to_f
|
||||
|
||||
# Extract the memory value properly by removing the "MiB" suffix
|
||||
mem_string = parts[1].split.first
|
||||
mem_part = mem_string.gsub(/[A-Za-z]+$/, "").to_f
|
||||
|
||||
return {timestamp: Time.utc, cpu: cpu_part, memory: mem_part}
|
||||
end
|
||||
end
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def check_dependencies
|
||||
{"docker", "jq", "bombardier"}.each do |cmd|
|
||||
process = Process.run("which", [cmd], output: Process::Redirect::Close)
|
||||
unless process.success?
|
||||
puts "Error: #{cmd} is not installed. Please install it to proceed."
|
||||
exit(1)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def setup_containers
|
||||
puts "Setting up..."
|
||||
|
||||
process = Process.run("docker", ["compose", "up", "-d"])
|
||||
unless process.success?
|
||||
puts "Failed to start Docker containers."
|
||||
exit(1)
|
||||
end
|
||||
|
||||
puts "Waiting for the application to be ready..."
|
||||
until begin
|
||||
HTTP::Client.get("#{SERVER_URL}/api/ping").success?
|
||||
rescue
|
||||
false
|
||||
end
|
||||
sleep 1.seconds
|
||||
end
|
||||
|
||||
puts "Seeding the database..."
|
||||
process = Process.run("docker", ["compose", "exec", "app", "sh", "-c", "sqlite3 ./sqlite/data.db < ./db/seed.sql"])
|
||||
|
||||
unless process.success?
|
||||
puts "Error on seeding database"
|
||||
exit(1)
|
||||
end
|
||||
|
||||
puts "Checking seed results..."
|
||||
until begin
|
||||
HTTP::Client.get(
|
||||
"#{API_URL}?limit=1",
|
||||
headers: HTTP::Headers{"X-Api-Key" => API_KEY}
|
||||
).success?
|
||||
rescue
|
||||
false
|
||||
end
|
||||
sleep 2.seconds
|
||||
end
|
||||
end
|
||||
|
||||
def run_benchmark
|
||||
puts "Fetching all created links from /api/links..."
|
||||
|
||||
response = HTTP::Client.get(
|
||||
"#{API_URL}?limit=10000",
|
||||
headers: HTTP::Headers{"X-Api-Key" => API_KEY}
|
||||
)
|
||||
|
||||
sleep 2.seconds
|
||||
unless response.success?
|
||||
puts "Failed to fetch links. Status: #{response.status_code}"
|
||||
exit(1)
|
||||
end
|
||||
|
||||
data = JSON.parse(response.body)
|
||||
links = data["data"].as_a.map { |link| link["refer"].as_s }
|
||||
|
||||
random_link = links.sample
|
||||
puts "Selected link for benchmarking: #{random_link}"
|
||||
|
||||
puts "Starting benchmark with Bombardier..."
|
||||
|
||||
sleep 2.seconds
|
||||
process = Process.new(
|
||||
"bombardier",
|
||||
["-n", NUMBER_OF_REQUESTS.to_s, "-l", "--disableKeepAlives", random_link],
|
||||
output: Process::Redirect::Inherit,
|
||||
error: Process::Redirect::Inherit
|
||||
)
|
||||
|
||||
status = process.wait
|
||||
|
||||
if status.success?
|
||||
puts "Benchmark completed successfully."
|
||||
else
|
||||
puts "Bombardier failed with error code: #{status.exit_code}"
|
||||
exit(1)
|
||||
end
|
||||
end
|
||||
|
||||
def analyze_resource_usage
|
||||
puts "Analyzing resource usage..."
|
||||
|
||||
sleep 2.seconds
|
||||
if File.exists?(STATS_FILE)
|
||||
lines = File.read_lines(STATS_FILE)
|
||||
# Skip header
|
||||
lines = lines[1..-1] if lines.size > 0
|
||||
|
||||
if lines.size > 0
|
||||
total_cpu = 0.0
|
||||
total_memory = 0.0
|
||||
peak_cpu = 0.0
|
||||
peak_memory = 0.0
|
||||
|
||||
lines.each do |line|
|
||||
fields = line.split("\t")
|
||||
if fields.size >= 3
|
||||
begin
|
||||
cpu = fields[1].to_f
|
||||
memory = fields[2].to_f
|
||||
|
||||
total_cpu += cpu
|
||||
total_memory += memory
|
||||
|
||||
# Track peaks in a single pass
|
||||
peak_cpu = cpu if cpu > peak_cpu
|
||||
peak_memory = memory if memory > peak_memory
|
||||
rescue
|
||||
# Skip invalid lines
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
avg_cpu = total_cpu / lines.size
|
||||
avg_memory = total_memory / lines.size
|
||||
|
||||
stats_summary = <<-STATS
|
||||
**** Resource Usage Statistics ****
|
||||
Measurements: #{lines.size}
|
||||
Average CPU Usage: #{avg_cpu.round(2)}%
|
||||
Average Memory Usage: #{avg_memory.round(2)} MiB
|
||||
Peak CPU Usage: #{peak_cpu.round(2)}%
|
||||
Peak Memory Usage: #{peak_memory.round(2)} MiB
|
||||
|
||||
STATS
|
||||
|
||||
File.open(STATS_FILE, "a") do |file|
|
||||
file.puts "\n" + stats_summary
|
||||
end
|
||||
|
||||
puts File.read(STATS_FILE)
|
||||
else
|
||||
puts "No resource usage data collected."
|
||||
end
|
||||
else
|
||||
puts "Resource usage file not found."
|
||||
end
|
||||
end
|
||||
|
||||
def cleanup
|
||||
Process.run("docker", ["compose", "down"])
|
||||
puts "Cleanup completed. Resource usage data saved in #{STATS_FILE}"
|
||||
end
|
||||
|
||||
def main
|
||||
check_dependencies
|
||||
setup_containers
|
||||
|
||||
monitor = ResourceMonitor.new(CONTAINER_NAME)
|
||||
monitor.start
|
||||
|
||||
begin
|
||||
run_benchmark
|
||||
|
||||
monitor.stop
|
||||
analyze_resource_usage
|
||||
ensure
|
||||
cleanup
|
||||
end
|
||||
end
|
||||
|
||||
main
|
||||
@@ -0,0 +1,279 @@
|
||||
#!/usr/bin/env crystal
|
||||
|
||||
require "http/client"
|
||||
require "json"
|
||||
|
||||
SERVER_URL = "http://localhost:4000"
|
||||
API_URL = "#{SERVER_URL}/api/links"
|
||||
API_KEY = "secure_api_key_1"
|
||||
NUMBER_OF_REQUESTS = 100000
|
||||
|
||||
APP_COMMAND = "./bit"
|
||||
APP_ARGS = [] of String # Add any arguments if needed
|
||||
STATS_FILE = "resource_usage.log"
|
||||
APP_LOG_FILE = "app_output.log"
|
||||
|
||||
class ResourceMonitor
|
||||
def initialize(@pid : Int32)
|
||||
@running = false
|
||||
@stats = [] of {timestamp: Time, cpu: Float64, memory: Float64}
|
||||
end
|
||||
|
||||
def start
|
||||
@running = true
|
||||
@stats.clear
|
||||
File.write(STATS_FILE, "Timestamp\tCPU(%)\tMemory(MiB)\n")
|
||||
|
||||
spawn do
|
||||
while @running
|
||||
if stat = capture_stats
|
||||
File.open(STATS_FILE, "a") do |file|
|
||||
file.puts "#{stat[:timestamp].to_unix}\t#{stat[:cpu]}\t#{stat[:memory]}"
|
||||
end
|
||||
@stats << stat
|
||||
end
|
||||
sleep 1.seconds
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def stop
|
||||
@running = false
|
||||
sleep 1.seconds
|
||||
end
|
||||
|
||||
private def capture_stats
|
||||
output = IO::Memory.new
|
||||
process = Process.run(
|
||||
"ps", ["-p", @pid.to_s, "-o", "%cpu,%mem,rss"],
|
||||
output: output
|
||||
)
|
||||
|
||||
if process.success?
|
||||
lines = output.to_s.strip.split("\n")
|
||||
if lines.size >= 2
|
||||
data_line = lines[1].strip.split
|
||||
if data_line.size >= 3
|
||||
cpu = data_line[0].to_f
|
||||
# RSS is in KB on macOS, convert to MiB
|
||||
memory_kb = data_line[2].to_f
|
||||
memory_mib = memory_kb / 1024.0
|
||||
|
||||
return {timestamp: Time.utc, cpu: cpu, memory: memory_mib}
|
||||
end
|
||||
end
|
||||
end
|
||||
nil
|
||||
end
|
||||
|
||||
def print_summary
|
||||
return if @stats.empty?
|
||||
|
||||
total_cpu = 0.0
|
||||
total_memory = 0.0
|
||||
peak_cpu = 0.0
|
||||
peak_memory = 0.0
|
||||
|
||||
@stats.each do |stat|
|
||||
total_cpu += stat[:cpu]
|
||||
total_memory += stat[:memory]
|
||||
peak_cpu = stat[:cpu] if stat[:cpu] > peak_cpu
|
||||
peak_memory = stat[:memory] if stat[:memory] > peak_memory
|
||||
end
|
||||
|
||||
avg_cpu = total_cpu / @stats.size
|
||||
avg_memory = total_memory / @stats.size
|
||||
|
||||
summary = <<-STATS
|
||||
|
||||
**** Resource Usage Statistics ****
|
||||
Measurements: #{@stats.size}
|
||||
Average CPU Usage: #{avg_cpu.round(2)}%
|
||||
Average Memory Usage: #{avg_memory.round(2)} MiB
|
||||
Peak CPU Usage: #{peak_cpu.round(2)}%
|
||||
Peak Memory Usage: #{peak_memory.round(2)} MiB
|
||||
|
||||
STATS
|
||||
|
||||
File.open(STATS_FILE, "a") do |file|
|
||||
file.puts summary
|
||||
end
|
||||
|
||||
puts summary
|
||||
end
|
||||
end
|
||||
|
||||
def start_application : Process
|
||||
puts "Starting application: #{APP_COMMAND}..."
|
||||
puts "Application output will be saved to: #{APP_LOG_FILE}"
|
||||
|
||||
# Open log file for writing
|
||||
log_file = File.open(APP_LOG_FILE, "w")
|
||||
|
||||
process = Process.new(
|
||||
APP_COMMAND,
|
||||
APP_ARGS,
|
||||
output: log_file,
|
||||
error: log_file
|
||||
)
|
||||
|
||||
puts "Application started with PID: #{process.pid}"
|
||||
process
|
||||
end
|
||||
|
||||
def stop_application(process : Process)
|
||||
puts "\nStopping application..."
|
||||
process.signal(Signal::TERM)
|
||||
|
||||
# Give it a few seconds to shut down gracefully
|
||||
sleep 3.seconds
|
||||
|
||||
# Force kill if still running
|
||||
begin
|
||||
process.signal(Signal::KILL)
|
||||
rescue
|
||||
# Process already terminated
|
||||
end
|
||||
|
||||
puts "Application stopped."
|
||||
end
|
||||
|
||||
def check_dependencies
|
||||
{"bombardier", "sqlite3"}.each do |cmd|
|
||||
process = Process.run("which", [cmd], output: Process::Redirect::Close)
|
||||
unless process.success?
|
||||
puts "Error: #{cmd} is not installed. Please install it to proceed."
|
||||
case cmd
|
||||
when "bombardier"
|
||||
puts " brew install bombardier"
|
||||
when "sqlite3"
|
||||
puts " brew install sqlite3"
|
||||
end
|
||||
exit(1)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def wait_for_server
|
||||
puts "Checking if server is ready at #{SERVER_URL}..."
|
||||
|
||||
30.times do
|
||||
begin
|
||||
if HTTP::Client.get("#{SERVER_URL}/api/ping").success?
|
||||
puts "Server is ready!"
|
||||
return
|
||||
end
|
||||
rescue
|
||||
# Server not ready yet
|
||||
end
|
||||
sleep 1.seconds
|
||||
print "."
|
||||
end
|
||||
|
||||
puts "\nError: Server is not responding. Please start your application first."
|
||||
exit(1)
|
||||
end
|
||||
|
||||
def run_benchmark
|
||||
puts "Fetching links from API..."
|
||||
|
||||
response = HTTP::Client.get(
|
||||
"#{API_URL}?limit=10000",
|
||||
headers: HTTP::Headers{"X-Api-Key" => API_KEY}
|
||||
)
|
||||
|
||||
unless response.success?
|
||||
puts "Failed to fetch links. Status: #{response.status_code}"
|
||||
puts "Make sure your server is running and the API key is correct."
|
||||
exit(1)
|
||||
end
|
||||
|
||||
data = JSON.parse(response.body)
|
||||
links = data["data"].as_a.map { |link| link["refer"].as_s }
|
||||
|
||||
if links.empty?
|
||||
puts "No links found. Please seed your database first."
|
||||
exit(1)
|
||||
end
|
||||
|
||||
random_link = links.sample
|
||||
puts "Selected link: #{random_link}"
|
||||
puts "\nStarting benchmark with #{NUMBER_OF_REQUESTS} requests..."
|
||||
|
||||
sleep 2.seconds
|
||||
|
||||
process = Process.new(
|
||||
"bombardier",
|
||||
["-n", NUMBER_OF_REQUESTS.to_s, "-l", "--disableKeepAlives", random_link],
|
||||
output: Process::Redirect::Inherit,
|
||||
error: Process::Redirect::Inherit
|
||||
)
|
||||
|
||||
status = process.wait
|
||||
|
||||
if status.success?
|
||||
puts "\nBenchmark completed successfully."
|
||||
else
|
||||
puts "\nBombardier failed with error code: #{status.exit_code}"
|
||||
exit(1)
|
||||
end
|
||||
end
|
||||
|
||||
def seed_database
|
||||
puts "Seeding database..."
|
||||
|
||||
unless File.exists?("./db/seed.sql")
|
||||
puts "Warning: ./db/seed.sql not found. Skipping database seeding."
|
||||
return
|
||||
end
|
||||
|
||||
unless File.exists?("./sqlite/data.db")
|
||||
puts "Warning: ./sqlite/data.db not found. Database may not be initialized."
|
||||
end
|
||||
|
||||
process = Process.run(
|
||||
"sqlite3",
|
||||
["./sqlite/data.db"],
|
||||
input: File.open("./db/seed.sql"),
|
||||
output: Process::Redirect::Inherit,
|
||||
error: Process::Redirect::Inherit
|
||||
)
|
||||
|
||||
if process.success?
|
||||
puts "Database seeded successfully."
|
||||
else
|
||||
puts "Warning: Database seeding failed. Continuing anyway..."
|
||||
end
|
||||
end
|
||||
|
||||
def main
|
||||
check_dependencies
|
||||
|
||||
app_process = start_application
|
||||
|
||||
begin
|
||||
wait_for_server
|
||||
|
||||
seed_database
|
||||
|
||||
# Give it a moment to settle
|
||||
sleep 2.seconds
|
||||
|
||||
monitor = ResourceMonitor.new(app_process.pid.to_i32)
|
||||
monitor.start
|
||||
|
||||
run_benchmark
|
||||
|
||||
monitor.stop
|
||||
monitor.print_summary
|
||||
|
||||
puts "\n**** Files Generated ****"
|
||||
puts " Resource stats: #{STATS_FILE}"
|
||||
puts " Application log: #{APP_LOG_FILE}"
|
||||
ensure
|
||||
# Always stop the application
|
||||
stop_application(app_process)
|
||||
end
|
||||
end
|
||||
|
||||
main
|
||||
+4
-4
@@ -2,11 +2,11 @@ version: 2.0
|
||||
shards:
|
||||
backtracer:
|
||||
git: https://github.com/sija/backtracer.cr.git
|
||||
version: 1.2.2
|
||||
version: 1.2.4
|
||||
|
||||
crecto:
|
||||
git: https://github.com/fridgerator/crecto.git
|
||||
version: 0.12.1
|
||||
version: 0.14.0
|
||||
|
||||
db:
|
||||
git: https://github.com/crystal-lang/crystal-db.git
|
||||
@@ -18,7 +18,7 @@ shards:
|
||||
|
||||
exception_page:
|
||||
git: https://github.com/crystal-loot/exception_page.git
|
||||
version: 0.4.1
|
||||
version: 0.5.0
|
||||
|
||||
ipaddress:
|
||||
git: https://github.com/sija/ipaddress.cr.git
|
||||
@@ -26,7 +26,7 @@ shards:
|
||||
|
||||
kemal:
|
||||
git: https://github.com/kemalcr/kemal.git
|
||||
version: 1.5.0
|
||||
version: 1.7.3
|
||||
|
||||
maxminddb:
|
||||
git: https://github.com/delef/maxminddb.cr.git
|
||||
|
||||
@@ -2,17 +2,20 @@ name: bit
|
||||
version: 1.5.3
|
||||
|
||||
authors:
|
||||
- Juan Rodriguez <sjdonado@icloud.com>
|
||||
- Juan Rodriguez Ronado <@sjdonado>
|
||||
|
||||
targets:
|
||||
bit:
|
||||
main: bit.cr
|
||||
cli:
|
||||
main: scripts/cli.cr
|
||||
benchmark:
|
||||
main: scripts/benchmark.cr
|
||||
|
||||
dependencies:
|
||||
kemal:
|
||||
github: kemalcr/kemal
|
||||
version: 1.7.3
|
||||
sqlite3:
|
||||
github: crystal-lang/crystal-sqlite3
|
||||
crecto:
|
||||
|
||||
Reference in New Issue
Block a user