From d1be2833183d278a01a0eaf7ace30cef8913ff04 Mon Sep 17 00:00:00 2001 From: sjdonado Date: Thu, 20 Mar 2025 12:01:13 +0100 Subject: [PATCH] refactor: rewrite benchmark in cr --- .gitignore | 2 +- benchmark.cr | 268 +++++++++++++++++++++++++++++++++++++++++++++++++++ benchmark.sh | 156 ------------------------------ db/seed.sql | 19 +--- 4 files changed, 274 insertions(+), 171 deletions(-) create mode 100755 benchmark.cr delete mode 100755 benchmark.sh diff --git a/.gitignore b/.gitignore index b6dfe0a..050e0f0 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,4 @@ /sqlite/ .env.production -resource_usage.csv +resource_usage.* diff --git a/benchmark.cr b/benchmark.cr new file mode 100755 index 0000000..17e50f2 --- /dev/null +++ b/benchmark.cr @@ -0,0 +1,268 @@ +#!/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" + +NUM_LINKS = 10_000 +NUM_REQUESTS = 100_000 +CONCURRENCY = 100 + +RESOURCE_USAGE_INTERVAL = 1 +CONTAINER_NAME = "bit" +STATS_FILE = "resource_usage.txt" + +class ResourceMonitor + def initialize(@container_name : String, @interval : Int32) + @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 + sleep @interval.seconds + 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 + mem_part = parts[1].split.first.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}", + headers: HTTP::Headers{"X-Api-Key" => API_KEY} + ).success? + rescue + false + end + sleep 1.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} + ) + + 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 } + + if links.size != NUM_LINKS + puts "Error: Expected #{NUM_LINKS} links but found #{links.size}." + exit(1) + end + + random_link = links.sample + puts "Selected link for benchmarking: #{random_link}" + + puts "Starting benchmark with Bombardier..." + + # Run bombardier for benchmarking + stdout = IO::Memory.new + stderr = IO::Memory.new + + process = Process.run( + "bombardier", + ["-c", CONCURRENCY.to_s, "-n", NUM_REQUESTS.to_s, random_link], + output: stdout, + error: stderr + ) + + unless process.success? + puts "Bombardier failed with error:" + puts stderr.to_s + exit(1) + end + + # Display bombardier results + puts stdout.to_s + puts "Benchmark completed." + + # Save bombardier results to file + File.write("bombardier_results.txt", stdout.to_s) + puts "Bombardier results saved to bombardier_results.txt" +end + +def analyze_resource_usage + puts "Analyzing resource usage..." + + # Read stats directly from file for more accurate results + 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 + + 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 + rescue + # Skip invalid lines + end + end + end + + avg_cpu = total_cpu / lines.size + avg_memory = total_memory / lines.size + + # Format statistics summary + 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: #{lines.max_by { |l| l.split("\t")[1].to_f rescue 0.0 }.split("\t")[1].to_f rescue 0.0}% + Peak Memory Usage: #{lines.max_by { |l| l.split("\t")[2].to_f rescue 0.0 }.split("\t")[2].to_f rescue 0.0} MiB + + STATS + + puts stats_summary + + # Append summary to stats file + File.open(STATS_FILE, "a") do |file| + file.puts "\n" + stats_summary + end + 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}" + puts "Bombardier results saved in bombardier_results.txt" +end + +def main + check_dependencies + setup_containers + + monitor = ResourceMonitor.new(CONTAINER_NAME, RESOURCE_USAGE_INTERVAL) + monitor.start + + begin + run_benchmark + + monitor.stop + analyze_resource_usage + ensure + cleanup + end +end + +main diff --git a/benchmark.sh b/benchmark.sh deleted file mode 100755 index f44691d..0000000 --- a/benchmark.sh +++ /dev/null @@ -1,156 +0,0 @@ -#!/bin/bash - -# Configuration variables -server_url="http://localhost:4000" -api_url="${server_url}/api/links" - -num_links=10000 -num_requests=100000 -concurrency=100 -create_links_concurrency=100 - -resource_usage_interval=1 -container_name="bit" - -check_dependencies() { - if ! command -v bombardier &> /dev/null; then - echo "Error: bombardier is not installed. Please install it to proceed." - exit 1 - fi - - if ! command -v jq &> /dev/null; then - echo "Error: jq is not installed. Please install it to proceed." - exit 1 - fi -} - -setup_containers() { - echo "Setting up..." - docker compose up -d - if [ $? -ne 0 ]; then - echo "Failed to start Docker containers." - exit 1 - fi - - output=$(docker compose exec -T app cli --create-user=Admin) - api_key=$(echo "$output" | awk -F' ' '/X-Api-Key:/{print $NF}') - echo "Captured API Key: $api_key" - - if [[ -z "$api_key" ]]; then - echo "Error: API key could not be retrieved." - exit 1 - fi - - echo "Waiting for the application to be ready..." - until curl --silent --head --fail --header "X-Api-Key: $api_key" "$server_url/api/ping"; do - sleep 2 - done -} - -monitor_resource_usage() { - echo "Starting resource usage monitoring..." - echo "Timestamp,CPU,Memory" > resource_usage.csv - while :; do - stats=$(docker stats --no-stream --format "{{.CPUPerc}},{{.MemUsage}}" $container_name) - cpu=$(echo $stats | awk -F',' '{print $1}' | sed 's/%//') - mem=$(echo $stats | awk -F',' '{print $2}' | awk '{print $1}') - timestamp=$(date +%s) - echo "$timestamp,$cpu,$mem" >> resource_usage.csv - sleep $resource_usage_interval - done -} - -create_links() { - local temp_file=$(mktemp) - - echo "Creating $num_links short links with $create_links_concurrency concurrent requests..." - for ((i=1; i<=num_links; i++)); do - url="https://httpbin.org/anything/$i" - - echo "--next" >> "$temp_file" - echo "--request POST" >> "$temp_file" - echo "--url \"$api_url\"" >> "$temp_file" - echo "--header \"X-Api-Key: $api_key\"" >> "$temp_file" - echo "--header \"Content-Type: application/json\"" >> "$temp_file" - echo "--data \"{ \\\"url\\\": \\\"$url\\\" }\"" >> "$temp_file" - done - - curl --parallel --parallel-immediate --parallel-max $create_links_concurrency --config "$temp_file" --silent --write-out "%{http_code}\n" > /dev/null - - echo "Link creation complete: $num_links links created using httpbin's anything endpoint." - rm -f "$temp_file" -} - -run_benchmark() { - echo "Fetching all created links from /api/links..." - all_links_response=$(curl --silent --request GET \ - --url "$api_url" \ - --header "X-Api-Key: $api_key" \ - --header "Content-Type: application/json") - - links=($(echo "$all_links_response" | jq -r '.data[] | .refer')) - if [[ ${#links[@]} -ne $num_links ]]; then - echo "Error: Expected $num_links links but found ${#links[@]}." - exit 1 - fi - - random_link="${links[RANDOM % ${#links[@]}]}" - echo "Selected link for benchmarking: $random_link" - - echo "Starting benchmark with Bombardier..." - bombardier -c $concurrency -n $num_requests "$random_link" - echo "Benchmark completed." -} - -analyze_resource_usage() { - echo "Analyzing resource usage..." - total_cpu=0 - total_mem=0 - count=0 - - while IFS=',' read -r timestamp cpu mem; do - # Skip header line and lines with empty cpu or mem values - if [[ $timestamp != "Timestamp" && -n $cpu && -n $mem ]]; then - mem=${mem%MiB} - - total_cpu=$(echo "$total_cpu + $cpu" | bc) - total_mem=$(echo "$total_mem + $mem" | bc) - ((count++)) - fi - done < resource_usage.csv - - avg_cpu=0.00 - avg_mem=0.00 - - if (( count > 0 )); then - avg_cpu=$(echo "scale=2; $total_cpu / $count" | bc) - avg_mem=$(echo "scale=2; $total_mem / $count" | bc) - fi - - echo "**** Results ****" - echo "Average CPU Usage: $avg_cpu%" - echo "Average Memory Usage: $avg_mem MiB" -} - -cleanup() { - rm -f resource_usage.csv - docker compose down -} - -main() { - check_dependencies - setup_containers - - monitor_resource_usage & # Start monitoring in the background - monitor_pid=$! - trap 'kill $monitor_pid; cleanup; exit' INT - - create_links - run_benchmark - - kill $monitor_pid - analyze_resource_usage - cleanup -} - -main diff --git a/db/seed.sql b/db/seed.sql index cf027f3..3e70fb9 100644 --- a/db/seed.sql +++ b/db/seed.sql @@ -1,32 +1,23 @@ --- Create 10 users INSERT INTO users (name, api_key) VALUES ('User 1', 'secure_api_key_1'), -('User 2', 'secure_api_key_2'), -('User 3', 'secure_api_key_3'), -('User 4', 'secure_api_key_4'), -('User 5', 'secure_api_key_5'), -('User 6', 'secure_api_key_6'), -('User 7', 'secure_api_key_7'), -('User 8', 'secure_api_key_8'), -('User 9', 'secure_api_key_9'), -('User 10', 'secure_api_key_10'); +('User 2', 'secure_api_key_2'); --- Create 1,000 links (100 per user) +-- Create 200,000 links (100,000 per user) WITH RECURSIVE link_numbers(n) AS ( SELECT 1 UNION ALL SELECT n+1 FROM link_numbers - LIMIT 1000 + LIMIT 200000 ) INSERT INTO links (user_id, slug, url) SELECT - ((n-1) % 10) + 1, -- User ID (1-10) + ((n-1) % 2) + 1, -- User ID (1-2) 'slug' || n, -- Unique slug 'https://sjdonado.com/page/' || n FROM link_numbers; --- Create 1,000,000 clicks (1,000 per link) +-- Create 200,000,000 clicks (1,000 per link) WITH RECURSIVE link_numbers(link_id) AS ( SELECT id FROM links ),