diff --git a/frameworks/Ruby/rack-app/Gemfile b/frameworks/Ruby/rack-app/Gemfile new file mode 100644 index 00000000000..2ad03adfb21 --- /dev/null +++ b/frameworks/Ruby/rack-app/Gemfile @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +source 'https://rubygems.org' + +gem 'rack-app' +gem 'rack-app-front_end' +gem 'iodine', '~> 0.7', platforms: %i[ruby windows] +gem 'irb' # for Ruby 3.5 +gem 'logger' # for Ruby 3.5 +gem 'json', '~> 2.10' +gem 'pg', '~> 1.5' +gem 'sequel', '~> 5.0' +gem 'sequel_pg', '~> 1.6', require: false diff --git a/frameworks/Ruby/rack-app/Gemfile.lock b/frameworks/Ruby/rack-app/Gemfile.lock new file mode 100644 index 00000000000..7da89e051ce --- /dev/null +++ b/frameworks/Ruby/rack-app/Gemfile.lock @@ -0,0 +1,51 @@ +GEM + remote: https://rubygems.org/ + specs: + concurrent-ruby (1.3.5) + date (3.5.0) + erb (5.1.3) + io-console (0.8.1) + irb (1.15.3) + pp (>= 0.6.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) + json (2.15.2) + logger (1.7.0) + nio4r (2.7.5) + pp (0.6.3) + prettyprint + prettyprint (0.2.0) + psych (5.2.6) + date + stringio + puma (7.1.0) + nio4r (~> 2.0) + rack (3.2.4) + rack-app (11.0.2) + rack (>= 3.0.0) + rackup + rackup (2.2.1) + rack (>= 3) + rdoc (6.15.1) + erb + psych (>= 4.0.0) + tsort + reline (0.6.2) + io-console (~> 0.5) + stringio (3.1.7) + tsort (0.2.0) + +PLATFORMS + arm64-darwin-24 + ruby + +DEPENDENCIES + concurrent-ruby + irb + json (~> 2.10) + logger + puma (~> 7.1) + rack-app + +BUNDLED WITH + 2.7.2 diff --git a/frameworks/Ruby/rack-app/README.md b/frameworks/Ruby/rack-app/README.md new file mode 100644 index 00000000000..585c970919e --- /dev/null +++ b/frameworks/Ruby/rack-app/README.md @@ -0,0 +1,44 @@ +# Rack-app Benchmarking Test + +rack-app is a minimalist web framework that focuses on simplicity and +maintainability. The framework is meant to be used by seasoned web developers. + +https://github.com/rack-app/rack-app + +### Test Type Implementation Source Code + +* [JSON Serialization](app.rb): "/json" +* [Single Database Query](app.rb): "/db" +* [Multiple Database Queries](app.rb): "/db?queries={#}" +* [Fortunes](app.rb): "/fortune" +* [Plaintext](app.rb): "/plaintext" + +## Important Libraries + +The tests were run with: + +* [Sequel](https://rubygems.org/gems/sequel) +* [PG](https://rubygems.org/gems/pg) + +## Test URLs + +### JSON + +http://localhost:8080/json + +### PLAINTEXT + +http://localhost:8080/plaintext + +### DB + +http://localhost:8080/db + +### QUERY + +http://localhost:8080/queries?queries= + +### FORTUNES + +http://localhost:8080/fortunes + diff --git a/frameworks/Ruby/rack-app/app.rb b/frameworks/Ruby/rack-app/app.rb new file mode 100644 index 00000000000..e5310fc012d --- /dev/null +++ b/frameworks/Ruby/rack-app/app.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require 'rack/app' +require 'rack/app/front_end' +require 'json' + +class App < Rack::App + MAX_PK = 10_000 + ID_RANGE = (1..10_000).freeze + ALL_IDS = ID_RANGE.to_a + QUERIES_MIN = 1 + QUERIES_MAX = 500 + JSON_TYPE = 'application/json' + HTML_TYPE = 'text/html; charset=utf-8' + PLAINTEXT_TYPE = 'text/plain' + + apply_extensions :front_end + + helpers do + def fortunes + fortunes = Fortune.all + fortunes << Fortune.new( + id: 0, + message: "Additional fortune added at request time." + ) + fortunes.sort_by!(&:message) + end + end + + get '/json' do + set_headers(JSON_TYPE) + { message: 'Hello, World!' }.to_json + end + + get '/db' do + set_headers(JSON_TYPE) + World.with_pk(rand1).values.to_json + end + + get '/queries' do + set_headers(JSON_TYPE) + ids = ALL_IDS.sample(bounded_queries) + DB.synchronize do + ids.map do |id| + World.with_pk(id).values + end + end.to_json + end + + get '/fortunes' do + set_headers(HTML_TYPE) + render 'fortunes.html.erb' + end + + get '/plaintext' do + set_headers(PLAINTEXT_TYPE) + 'Hello, World!' + end + + private + + # Return a random number between 1 and MAX_PK + def rand1 + rand(MAX_PK).succ + end + + def bounded_queries + queries = params['queries'].to_i + queries.clamp(QUERIES_MIN, QUERIES_MAX) + end + + def set_headers(content_type) + response.headers[::Rack::CONTENT_TYPE] = content_type + response.headers['Server'] = 'rack-app' + end +end diff --git a/frameworks/Ruby/rack-app/app/fortunes.html.erb b/frameworks/Ruby/rack-app/app/fortunes.html.erb new file mode 100644 index 00000000000..56c5c540270 --- /dev/null +++ b/frameworks/Ruby/rack-app/app/fortunes.html.erb @@ -0,0 +1,12 @@ + + + Fortunes + + + + <% fortunes.each do |record| %> + + <% end %> +
idmessage
<%= record.id %><%= ERB::Escape.html_escape(record.message) %>
+ + diff --git a/frameworks/Ruby/rack-app/benchmark_config.json b/frameworks/Ruby/rack-app/benchmark_config.json new file mode 100644 index 00000000000..d25acd9a641 --- /dev/null +++ b/frameworks/Ruby/rack-app/benchmark_config.json @@ -0,0 +1,27 @@ +{ + "framework": "rack-app", + "tests": [ + { + "default": { + "json_url": "/json", + "plaintext_url": "/plaintext", + "db_url": "/db", + "query_url": "/queries?queries=", + "fortune_url": "/fortunes", + "port": 8080, + "approach": "Realistic", + "classification": "Micro", + "orm": "Full", + "database": "Postgres", + "framework": "rack-app", + "language": "Ruby", + "platform": "Mri", + "webserver": "Iodine", + "os": "Linux", + "database_os": "Linux", + "display_name": "rack-app", + "notes": "" + } + } + ] +} diff --git a/frameworks/Ruby/rack-app/boot.rb b/frameworks/Ruby/rack-app/boot.rb new file mode 100644 index 00000000000..7711f76a0a1 --- /dev/null +++ b/frameworks/Ruby/rack-app/boot.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true +require 'bundler/setup' +require 'time' + +MAX_PK = 10_000 +ID_RANGE = (1..MAX_PK).freeze +ALL_IDS = ID_RANGE.to_a +QUERIES_MIN = 1 +QUERIES_MAX = 500 +SEQUEL_NO_ASSOCIATIONS = true +#SERVER_STRING = "Sinatra" + +Bundler.require(:default) # Load core modules + +def connect(dbtype) + Bundler.require(dbtype) # Load database-specific modules + + opts = {} + + adapter = 'postgresql' + + # Determine threading/thread pool size and timeout + if defined?(Puma) && (threads = Puma.cli_config.options.fetch(:max_threads)) > 1 + opts[:max_connections] = threads + opts[:pool_timeout] = 10 + else + opts[:max_connections] = 512 + end + + Sequel.connect \ + '%{adapter}://%{host}/%{database}?user=%{user}&password=%{password}' % { + adapter: adapter, + host: 'tfb-database', + database: 'hello_world', + user: 'benchmarkdbuser', + password: 'benchmarkdbpass' + }, opts +end + +DB = connect 'postgres' + +# Define ORM models +class World < Sequel::Model(:World) + def_column_alias(:randomnumber, :randomNumber) if DB.database_type == :mysql + + def self.batch_update(worlds) + if DB.database_type == :mysql + worlds.map(&:save_changes) + else + ids = [] + sql = String.new("UPDATE world SET randomnumber = CASE id ") + worlds.each do |world| + sql << "when #{world.id} then #{world.randomnumber} " + ids << world.id + end + sql << "ELSE randomnumber END WHERE id IN ( #{ids.join(',')})" + DB.run(sql) + end + end +end + +class Fortune < Sequel::Model(:Fortune) + # Allow setting id to zero (0) per benchmark requirements + unrestrict_primary_key +end + +[World, Fortune].each(&:freeze) +DB.freeze diff --git a/frameworks/Ruby/rack-app/config.ru b/frameworks/Ruby/rack-app/config.ru new file mode 100644 index 00000000000..27540c2f4ee --- /dev/null +++ b/frameworks/Ruby/rack-app/config.ru @@ -0,0 +1,5 @@ +# frozen_string_literal: true +require_relative 'boot' +require_relative 'app' + +run App diff --git a/frameworks/Ruby/rack-app/config/auto_tune.rb b/frameworks/Ruby/rack-app/config/auto_tune.rb new file mode 100644 index 00000000000..1e075f56911 --- /dev/null +++ b/frameworks/Ruby/rack-app/config/auto_tune.rb @@ -0,0 +1,43 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Instantiate about one process per X MiB of available memory, scaling up to as +# close to MAX_THREADS as possible while observing an upper bound based on the +# number of virtual/logical CPUs. If there are fewer processes than +# MAX_THREADS, add threads per process to reach MAX_THREADS. +require 'etc' + +KB_PER_WORKER = 64 * 1_024 # average of peak PSS of single-threaded processes (watch smem -k) +MIN_WORKERS = 2 +MAX_WORKERS_PER_VCPU = 1.25 # virtual/logical +MIN_THREADS_PER_WORKER = 1 +MAX_THREADS = Integer(ENV['MAX_CONCURRENCY'] || 256) + +def meminfo(arg) + File.open('/proc/meminfo') do |f| + f.each_line do |line| + key, value = line.split(/:\s+/) + return value.split(/\s+/).first.to_i if key == arg + end + end + + raise "Unable to find `#{arg}' in /proc/meminfo!" +end + +def auto_tune + avail_mem = meminfo('MemAvailable') * 0.8 - MAX_THREADS * 1_024 + + workers = [ + [(1.0 * avail_mem / KB_PER_WORKER).floor, MIN_WORKERS].max, + [(Etc.nprocessors * MAX_WORKERS_PER_VCPU).ceil, MIN_WORKERS].max + ].min + + threads_per_worker = [ + workers < MAX_THREADS ? (1.0 * MAX_THREADS / workers).ceil : -Float::INFINITY, + MIN_THREADS_PER_WORKER + ].max + + [workers, threads_per_worker] +end + +p auto_tune if $PROGRAM_NAME == __FILE__ diff --git a/frameworks/Ruby/rack-app/config/puma.rb b/frameworks/Ruby/rack-app/config/puma.rb new file mode 100644 index 00000000000..1b6d05d8ac0 --- /dev/null +++ b/frameworks/Ruby/rack-app/config/puma.rb @@ -0,0 +1,10 @@ +require_relative 'auto_tune' + +# FWBM only... use the puma_auto_tune gem in production! +_num_workers, num_threads = auto_tune + +threads num_threads + +before_fork do + Sequel::DATABASES.each(&:disconnect) +end diff --git a/frameworks/Ruby/rack-app/rack-app.dockerfile b/frameworks/Ruby/rack-app/rack-app.dockerfile new file mode 100644 index 00000000000..2b24fcab8bd --- /dev/null +++ b/frameworks/Ruby/rack-app/rack-app.dockerfile @@ -0,0 +1,21 @@ +FROM ruby:3.5-rc + +ENV RUBY_YJIT_ENABLE=1 + +# Use Jemalloc +RUN apt-get update && \ + apt-get install -y --no-install-recommends libjemalloc2 +ENV LD_PRELOAD=libjemalloc.so.2 + +WORKDIR /rack-app + +COPY Gemfile* ./ + +ENV BUNDLE_FORCE_RUBY_PLATFORM=true +RUN bundle install --jobs=8 + +COPY . . + +EXPOSE 8080 + +CMD bundle exec iodine -p 8080 -w $(ruby config/auto_tune.rb | grep -Eo '[0-9]+' | head -n 1)