diff --git a/frameworks/roda/Dockerfile b/frameworks/roda/Dockerfile new file mode 100644 index 00000000..3ce6e776 --- /dev/null +++ b/frameworks/roda/Dockerfile @@ -0,0 +1,24 @@ +FROM ruby:4.0-slim + +RUN apt-get update && \ + apt-get install -y --no-install-recommends build-essential libsqlite3-dev libjemalloc2 && \ + rm -rf /var/lib/apt/lists/* + +# Use Jemalloc +ENV LD_PRELOAD=libjemalloc.so.2 + +ENV RUBY_YJIT_ENABLE=1 +ENV RUBY_MN_THREADS=1 +ENV RACK_ENV=production +ENV WEB_CONCURRENCY=auto + +WORKDIR /app + +COPY Gemfile . +RUN bundle install --jobs=$(nproc) + +COPY . . + +EXPOSE 8080 + +CMD ["bundle", "exec", "puma", "-C", "puma.rb"] diff --git a/frameworks/roda/Gemfile b/frameworks/roda/Gemfile new file mode 100644 index 00000000..9e31196b --- /dev/null +++ b/frameworks/roda/Gemfile @@ -0,0 +1,7 @@ +source 'https://rubygems.org' + +gem 'roda', '~> 3.100' +gem 'puma', '~> 7.2' +gem 'sqlite3', '~> 2.9' +gem 'json' +gem 'concurrent-ruby' diff --git a/frameworks/roda/app.rb b/frameworks/roda/app.rb new file mode 100644 index 00000000..a9d5714d --- /dev/null +++ b/frameworks/roda/app.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +require 'bundler/setup' +Bundler.require(:default) + +require 'zlib' + +class App < Roda + # Load dataset + dataset_path = ENV.fetch('DATASET_PATH', '/data/dataset.json') + if File.exist?(dataset_path) + opts[:dataset_items] = JSON.parse(File.read(dataset_path)) + end + + # Large dataset for compression + large_path = '/data/dataset-large.json' + if File.exist?(large_path) + raw = JSON.parse(File.read(large_path)) + items = raw.map do |d| + d.merge('total' => (d['price'] * d['quantity'] * 100).round / 100.0) + end + opts[:large_json_payload] = JSON.generate({ 'items' => items, 'count' => items.length }) + end + + # SQLite + opts[:db_available] = File.exist?('/data/benchmark.db') + + DB_QUERY = 'SELECT id, name, category, price, quantity, active, tags, rating_score, rating_count FROM items WHERE price BETWEEN ? AND ? LIMIT 50' + + plugin :default_headers, 'Server' => 'roda' + plugin :halt + plugin :streaming + + route do |r| + r.root { 'ok' } + + r.is 'pipeline' do + response[RodaResponseHeaders::CONTENT_TYPE] = 'text/plain' + 'ok' + end + + r.is('baseline11') { handle_baseline11 } + + r.is 'baseline2' do + total = 0 + request.GET.each do |_k, v| + total += v.to_i + end + response[RodaResponseHeaders::CONTENT_TYPE] = 'text/plain' + total.to_s + end + + r.is 'json' do + dataset = opts[:dataset_items] + r.halt 500, 'No dataset' unless dataset + items = dataset.map do |d| + d.merge('total' => (d['price'] * d['quantity'] * 100).round / 100.0) + end + response[RodaResponseHeaders::CONTENT_TYPE] = 'application/json' + JSON.generate({ 'items' => items, 'count' => items.length }) + end + + r.is 'compression' do + payload = opts[:large_json_payload] + r.halt 500, 'No dataset' unless payload + sio = StringIO.new + gz = Zlib::GzipWriter.new(sio, 1) + gz.write(payload) + gz.close + response[RodaResponseHeaders::CONTENT_TYPE] = 'application/json' + response[RodaResponseHeaders::CONTENT_ENCODING] = 'gzip' + sio.string + end + + r.is 'db' do + unless opts[:db_available] + response[RodaResponseHeaders::CONTENT_TYPE] = 'application/json' + return '{"items":[],"count":0}' + end + min_val = (request.params['min'] || 10).to_i + max_val = (request.params['max'] || 50).to_i + db = get_db + rows = db.execute(DB_QUERY, [min_val, max_val]) + items = rows.map do |row| + { + 'id' => row['id'], 'name' => row['name'], 'category' => row['category'], + 'price' => row['price'], 'quantity' => row['quantity'], 'active' => row['active'] == 1, + 'tags' => JSON.parse(row['tags']), + 'rating' => { 'score' => row['rating_score'], 'count' => row['rating_count'] } + } + end + response[RodaResponseHeaders::CONTENT_TYPE] = 'application/json' + JSON.generate({ 'items' => items, 'count' => items.length }) + end + end + + def handle_baseline11 + total = 0 + request.GET.each do |_k, v| + total += v.to_i + end + if request.post? + request.body.rewind + body_str = request.body.read.strip + total += body_str.to_i + end + response[RodaResponseHeaders::CONTENT_TYPE] = 'text/plain' + total.to_s + end + + def get_db + Thread.current[:roda_db] ||= begin + db = SQLite3::Database.new('/data/benchmark.db', readonly: true) + db.execute('PRAGMA mmap_size=268435456') + db.results_as_hash = true + db + end + end +end diff --git a/frameworks/roda/config.ru b/frameworks/roda/config.ru new file mode 100644 index 00000000..1ae15b10 --- /dev/null +++ b/frameworks/roda/config.ru @@ -0,0 +1,21 @@ +require_relative 'app' + +# Rack middleware to handle unknown HTTP methods before Puma/Sinatra +class MethodGuard + KNOWN = %w[GET POST PUT DELETE PATCH HEAD OPTIONS TRACE CONNECT].freeze + + def initialize(app) + @app = app + end + + def call(env) + if KNOWN.include?(env['REQUEST_METHOD']) + @app.call(env) + else + [405, { 'content-type' => 'text/plain', 'server' => 'roda' }, ['Method Not Allowed']] + end + end +end + +use MethodGuard +run App diff --git a/frameworks/roda/meta.json b/frameworks/roda/meta.json new file mode 100644 index 00000000..c3b8d56e --- /dev/null +++ b/frameworks/roda/meta.json @@ -0,0 +1,18 @@ +{ + "display_name": "Roda", + "language": "Ruby", + "type": "framework", + "engine": "puma", + "description": "Roda routing tree web toolkit", + "repo": "https://github.com/jeremyevans/roda", + "enabled": true, + "tests": [ + "baseline", + "pipelined", + "noisy", + "limited-conn", + "json", + "compression", + "mixed" + ] +} diff --git a/frameworks/roda/puma.rb b/frameworks/roda/puma.rb new file mode 100644 index 00000000..b81c5b79 --- /dev/null +++ b/frameworks/roda/puma.rb @@ -0,0 +1,12 @@ +threads 4, 4 + +bind 'tcp://0.0.0.0:8080' + +# Allow all HTTP methods so unknown ones reach Rack middleware (returned as 405) +supported_http_methods :any + +preload_app! + +before_fork do + # Close any inherited DB connections +end