├── .dockerignore ├── .gitignore ├── Dockerfile ├── Gemfile ├── Gemfile.lock ├── Makefile ├── README.md ├── app ├── database_adapter.rb ├── people_repository.rb └── person_serializer.rb ├── config.ru ├── docker-compose-prod.yml ├── docker-compose.yml ├── init.sql ├── nginx.conf ├── nginx.prod.conf ├── postgresql.conf └── postgresql.prod.conf /.dockerignore: -------------------------------------------------------------------------------- 1 | stress-test 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | stress-test 2 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ruby:3.2 AS base 2 | ENV RUBY_YJIT_ENABLE=1 3 | WORKDIR /app 4 | 5 | FROM base AS prod 6 | COPY Gemfile . 7 | COPY Gemfile.lock . 8 | RUN bundle install 9 | COPY . . 10 | EXPOSE 3000 11 | CMD ["rackup"] 12 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | # Database 6 | gem 'pg' 7 | gem 'connection_pool' 8 | 9 | # Rack 10 | gem 'rack' 11 | gem 'puma' 12 | gem 'falcon' 13 | 14 | # Rack apps 15 | gem 'chespirito' 16 | 17 | gem 'async' 18 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | adelnor (0.0.6) 5 | rack 6 | async (2.6.4) 7 | console (~> 1.10) 8 | fiber-annotation 9 | io-event (~> 1.1) 10 | timers (~> 4.1) 11 | async-container (0.16.12) 12 | async 13 | async-io 14 | async-http (0.60.2) 15 | async (>= 1.25) 16 | async-io (>= 1.28) 17 | async-pool (>= 0.2) 18 | protocol-http (~> 0.24.0) 19 | protocol-http1 (~> 0.15.0) 20 | protocol-http2 (~> 0.15.0) 21 | traces (>= 0.10.0) 22 | async-http-cache (0.4.3) 23 | async-http (~> 0.56) 24 | async-io (1.35.0) 25 | async 26 | async-pool (0.4.0) 27 | async (>= 1.25) 28 | build-environment (1.13.0) 29 | chespirito (0.0.3) 30 | adelnor 31 | rack 32 | connection_pool (2.4.1) 33 | console (1.23.2) 34 | fiber-annotation 35 | fiber-local 36 | falcon (0.42.3) 37 | async 38 | async-container (~> 0.16.0) 39 | async-http (~> 0.57) 40 | async-http-cache (~> 0.4.0) 41 | async-io (~> 1.22) 42 | build-environment (~> 1.13) 43 | bundler 44 | localhost (~> 1.1) 45 | openssl (~> 3.0) 46 | process-metrics (~> 0.2.0) 47 | protocol-rack (~> 0.1) 48 | samovar (~> 2.1) 49 | fiber-annotation (0.2.0) 50 | fiber-local (1.0.0) 51 | io-event (1.3.2) 52 | localhost (1.1.10) 53 | mapping (1.1.1) 54 | nio4r (2.5.9) 55 | openssl (3.1.0) 56 | pg (1.5.3) 57 | process-metrics (0.2.1) 58 | console (~> 1.8) 59 | samovar (~> 2.1) 60 | protocol-hpack (1.4.2) 61 | protocol-http (0.24.7) 62 | protocol-http1 (0.15.1) 63 | protocol-http (~> 0.22) 64 | protocol-http2 (0.15.1) 65 | protocol-hpack (~> 1.4) 66 | protocol-http (~> 0.18) 67 | protocol-rack (0.2.6) 68 | protocol-http (~> 0.23) 69 | rack (>= 1.0) 70 | puma (6.3.1) 71 | nio4r (~> 2.0) 72 | rack (2.2.8) 73 | samovar (2.2.0) 74 | console (~> 1.0) 75 | mapping (~> 1.0) 76 | timers (4.3.5) 77 | traces (0.11.1) 78 | 79 | PLATFORMS 80 | x86_64-linux 81 | 82 | DEPENDENCIES 83 | async 84 | chespirito 85 | connection_pool 86 | falcon 87 | pg 88 | puma 89 | rack 90 | 91 | BUNDLED WITH 92 | 2.4.10 93 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SHELL = /bin/bash 2 | .ONESHELL: 3 | .DEFAULT_GOAL: help 4 | 5 | help: ## Prints available commands 6 | @awk 'BEGIN {FS = ":.*##"; printf "Usage: make \033[36m\033[0m\n"} /^[.a-zA-Z_-]+:.*?##/ { printf " \033[36m%-25s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) 7 | 8 | bundle.install: 9 | @docker-compose run api1 bundle 10 | 11 | start.dev: ## Start the rinha in Dev 12 | @docker-compose up -d nginx 13 | 14 | start.prod: ## Start the rinha in Prod 15 | @docker-compose -f docker-compose-prod.yml up -d nginx 16 | 17 | docker.stats: ## Show docker stats 18 | @docker stats --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.MemPerc}}" 19 | 20 | health.check: ## Check the stack is healthy 21 | @curl -v http://localhost:9999/contagem-pessoas 22 | 23 | stress.it: ## Run stress tests 24 | @sh stress-test/run-test.sh 25 | 26 | docker.build: ## Build the docker image 27 | @docker build -t leandronsp/rinha-backend-ruby --target prod . 28 | 29 | docker.push: ## Push the docker image 30 | @docker push leandronsp/rinha-backend-ruby 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rinha-backend-ruby 2 | 3 | ``` 4 | _ _ _ _ _ _ 5 | _ _(_)_ _ | |_ __ _ | |__ __ _ __| |_____ _ _ __| | _ _ _ _| |__ _ _ 6 | | '_| | ' \| ' \/ _` | | '_ \/ _` / _| / / -_) ' \/ _` | | '_| || | '_ \ || | 7 | |_| |_|_||_|_||_\__,_| |_.__/\__,_\__|_\_\___|_||_\__,_| |_| \_,_|_.__/\_, | 8 | |__/ 9 | ``` 10 | 11 | Yet another Ruby version for [rinha do backend](https://github.com/zanfranceschi/rinha-de-backend-2023-q3) 12 | 13 | ## Requirements 14 | 15 | * [Docker](https://docs.docker.com/get-docker/) 16 | * [curl](https://curl.se/download.html) 17 | * [Gatling](https://gatling.io/open-source/), a performance testing tool 18 | * Make (optional) 19 | 20 | ## Stack 21 | 22 | * 2 Ruby 3.2 [+YJIT](https://shopify.engineering/ruby-yjit-is-production-ready) apps 23 | * 1 PostgreSQL 24 | * 1 NGINX 25 | 26 | ## Usage 27 | 28 | ```bash 29 | $ make help 30 | 31 | Usage: make 32 | help Prints available commands 33 | start.dev Start the rinha in Dev 34 | start.prod Start the rinha in Prod 35 | docker.stats Show docker stats 36 | health.check Check the stack is healthy 37 | stress.it Run stress tests 38 | docker.build Build the docker image 39 | docker.push Push the docker image 40 | ``` 41 | 42 | ## Starting the app 43 | 44 | You can start the stack by using the [public image](https://hub.docker.com/r/leandronsp/rinha-backend-ruby) `leandronsp/rinha-backend-ruby` with the following command: 45 | 46 | ```bash 47 | $ docker compose -f docker-compose-prod.yml up -d nginx 48 | 49 | # Or using make 50 | $ make start.prod 51 | ``` 52 | 53 | Or, in case you want to run the stack using local build and volumes: 54 | 55 | ```bash 56 | $ docker compose up -d nginx 57 | 58 | # Or using make 59 | $ make start.dev 60 | ``` 61 | 62 | Then perform a health check to ensure the app is running correctly: 63 | 64 | ```bash 65 | $ curl -v http://localhost:9999/contagem-pessoas 66 | 67 | # Or 68 | $ make health.check 69 | ``` 70 | 71 | ## Unleash the madness 72 | 73 | Make sure you copy all the resource/simulation files and Gatling script to the project and: 74 | 75 | ```bash 76 | $ make stress.it 77 | $ open stress-test/user-files/results/**/index.html 78 | ``` 79 | 80 | ---- 81 | 82 | [ASCII art generator](http://www.network-science.de/ascii/) 83 | -------------------------------------------------------------------------------- /app/database_adapter.rb: -------------------------------------------------------------------------------- 1 | require 'pg' 2 | require 'connection_pool' 3 | 4 | class DatabaseAdapter 5 | POOL_SIZE = ENV['DB_POOL_SIZE'] || 5 6 | 7 | def self.pool 8 | @pool ||= ConnectionPool.new(size: POOL_SIZE, timeout: 300) do 9 | PG.connect(configuration) 10 | end 11 | end 12 | 13 | def self.new_connection 14 | PG.connect(configuration) 15 | end 16 | 17 | def self.configuration 18 | base_config = { 19 | host: 'postgres', 20 | dbname: 'postgres', 21 | user: 'postgres', 22 | password: 'postgres' 23 | } 24 | 25 | return base_config unless ENV['PGBOUNCER_ENABLED'] 26 | 27 | base_config.merge({ 28 | host: 'pgbouncer', 29 | port: 6432 30 | }) 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /app/people_repository.rb: -------------------------------------------------------------------------------- 1 | require 'securerandom' 2 | require 'date' 3 | 4 | require_relative 'database_adapter' 5 | 6 | class PeopleRepository 7 | class ValidationError < StandardError; end; 8 | 9 | def search(term) 10 | sql = <<~SQL 11 | SELECT id, name, nickname, birth_date, stack 12 | FROM people WHERE search LIKE $1 13 | LIMIT 50 14 | SQL 15 | 16 | execute_with_params(sql, ["%#{term.downcase}%"]) 17 | end 18 | 19 | def find(id) 20 | sql = <<~SQL 21 | SELECT id, name, nickname, birth_date, stack 22 | FROM people WHERE id = $1 23 | SQL 24 | 25 | execute_with_params(sql, [id]).first 26 | end 27 | 28 | def create_person(nickname, name, birth_date, stack) 29 | SecureRandom.uuid.tap do |uuid| 30 | validate_str!(nickname) 31 | validate_str!(name) 32 | validate_date!(birth_date) 33 | 34 | validate_length!(nickname, 32) 35 | validate_length!(name, 100) 36 | 37 | sql = <<~SQL 38 | INSERT INTO people (id, nickname, name, birth_date, stack) 39 | VALUES ($1, $2, $3, $4, $5) 40 | SQL 41 | 42 | execute_with_params(sql, 43 | [uuid, nickname, name, birth_date, cast_stack(stack)], 44 | ) 45 | end 46 | end 47 | 48 | def count 49 | execute_with_params("SELECT COUNT(*) FROM people", []) 50 | .first['count'] 51 | .to_i 52 | end 53 | 54 | private 55 | 56 | def cast_stack(stack) 57 | return unless stack 58 | return unless stack.respond_to?(:map) 59 | 60 | stack.join(' ') 61 | end 62 | 63 | def validate_str!(str) 64 | raise ValidationError unless str.is_a?(String) 65 | end 66 | 67 | def validate_date!(date) 68 | Date.parse(date) rescue raise ValidationError 69 | end 70 | 71 | def validate_array_of_str!(arr) 72 | raise ValidationError unless arr.respond_to?(:each) 73 | 74 | arr.each do |str| 75 | validate_str!(str) 76 | validate_length!(str, 32) 77 | end 78 | end 79 | 80 | def validate_length!(str, length) 81 | raise ValidationError if str.length > length 82 | end 83 | 84 | def execute_with_params(sql, params) 85 | DatabaseAdapter.pool.with do |conn| 86 | conn.exec_params(sql, params) 87 | end 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /app/person_serializer.rb: -------------------------------------------------------------------------------- 1 | class PersonSerializer 2 | def initialize(person) 3 | @person = person 4 | end 5 | 6 | def serialize 7 | return {} unless @person 8 | 9 | { 10 | id: @person['id'], 11 | apelido: @person['nickname'], 12 | nome: @person['name'], 13 | nascimento: @person['birth_date'], 14 | stack: (@person['stack'] || '').split(' ') 15 | } 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | require 'json' 2 | require 'chespirito' 3 | 4 | require 'falcon' 5 | require 'rack/handler/falcon' 6 | 7 | require 'puma' 8 | require 'rack/handler/puma' 9 | 10 | require_relative 'app/people_repository' 11 | require_relative 'app/person_serializer' 12 | 13 | class PeopleController < Chespirito::Controller 14 | PG_EXCEPTIONS = [ 15 | PG::StringDataRightTruncation, 16 | PG::InvalidDatetimeFormat, 17 | PG::DatetimeFieldOverflow, 18 | PG::NotNullViolation, 19 | PG::UniqueViolation, 20 | PeopleRepository::ValidationError 21 | ].freeze 22 | 23 | def search 24 | if term = request.params['t'] 25 | repository = PeopleRepository.new 26 | results = repository.search(term) 27 | 28 | serialized = results.map do |person| 29 | PersonSerializer.new(person).serialize 30 | end 31 | 32 | response.body = serialized.to_json 33 | response.status = 200 34 | response.headers['Content-Type'] = 'application/json' 35 | else 36 | response.status = 400 37 | end 38 | end 39 | 40 | def show 41 | repository = PeopleRepository.new 42 | person = repository.find(request.params['id']) 43 | 44 | response.body = PersonSerializer.new(person).serialize.to_json 45 | response.status = 200 46 | response.headers['Content-Type'] = 'application/json' 47 | end 48 | 49 | def create 50 | repository = PeopleRepository.new 51 | 52 | uuid = repository.create_person( 53 | request.params['apelido'], 54 | request.params['nome'], 55 | request.params['nascimento'], 56 | request.params['stack'] 57 | ) 58 | 59 | response.status = 201 60 | response.headers['Location'] = "/pessoas/#{uuid}" 61 | rescue *PG_EXCEPTIONS 62 | response.status = 422 63 | end 64 | 65 | def count 66 | repository = PeopleRepository.new 67 | 68 | response.body = repository.count.to_s 69 | response.status = 200 70 | response.headers['Content-Type'] = 'text/plain' 71 | end 72 | end 73 | 74 | RinhaApp = Chespirito::App.configure do |app| 75 | app.register_route('GET', '/pessoas', [PeopleController, :search]) 76 | app.register_route('POST', '/pessoas', [PeopleController, :create]) 77 | app.register_route('GET', '/pessoas/:id', [PeopleController, :show]) 78 | app.register_route('GET', '/contagem-pessoas', [PeopleController, :count]) 79 | end 80 | 81 | Rack::Handler::Falcon.run RinhaApp, Port: 3000, Host: '0.0.0.0' 82 | #Rack::Handler::Puma.run RinhaApp, Port: 3000, Threads: '0:16' 83 | #Adelnor::Server.run RinhaApp, 3000 84 | -------------------------------------------------------------------------------- /docker-compose-prod.yml: -------------------------------------------------------------------------------- 1 | version: '3.9' 2 | 3 | services: 4 | api1: &api 5 | image: leandronsp/rinha-backend-ruby 6 | container_name: api1 7 | environment: 8 | - RUBY_YJIT_ENABLE=1 9 | - USE_POOL=1 10 | - WEB_CONCURRENCY=3 11 | depends_on: 12 | - postgres 13 | deploy: 14 | resources: 15 | limits: 16 | cpus: '0.2' 17 | memory: '0.9GB' 18 | 19 | api2: 20 | <<: *api 21 | container_name: api2 22 | 23 | postgres: 24 | image: postgres 25 | container_name: postgres 26 | environment: 27 | - POSTGRES_PASSWORD=postgres 28 | ports: 29 | - 5432:5432 30 | volumes: 31 | - ./init.sql:/docker-entrypoint-initdb.d/init.sql 32 | - ./postgresql.prod.conf:/etc/postgresql/postgresql.conf 33 | command: postgres -c config_file=/etc/postgresql/postgresql.conf 34 | deploy: 35 | resources: 36 | limits: 37 | cpus: '0.9' 38 | memory: '1GB' 39 | 40 | nginx: 41 | image: nginx 42 | container_name: nginx 43 | volumes: 44 | - ./nginx.prod.conf:/etc/nginx/nginx.conf:ro 45 | ports: 46 | - 9999:9999 47 | depends_on: 48 | - api1 49 | - api2 50 | deploy: 51 | resources: 52 | limits: 53 | cpus: '0.2' 54 | memory: '0.2GB' 55 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.9' 2 | 3 | services: 4 | api1: &api 5 | build: 6 | context: . 7 | target: base 8 | container_name: api1 9 | environment: 10 | - RUBY_YJIT_ENABLE=1 11 | - DB_POOL_SIZE=10 12 | command: rackup 13 | volumes: 14 | - .:/app 15 | - rubygems:/usr/local/bundle 16 | depends_on: 17 | - postgres 18 | deploy: 19 | resources: 20 | limits: 21 | cpus: '0.2' 22 | memory: '0.3GB' 23 | 24 | api2: 25 | <<: *api 26 | container_name: api2 27 | 28 | postgres: 29 | image: postgres 30 | container_name: postgres 31 | environment: 32 | - POSTGRES_PASSWORD=postgres 33 | ports: 34 | - 5432:5432 35 | volumes: 36 | - ./init.sql:/docker-entrypoint-initdb.d/init.sql 37 | - ./postgresql.conf:/etc/postgresql/postgresql.conf 38 | command: postgres -c config_file=/etc/postgresql/postgresql.conf 39 | deploy: 40 | resources: 41 | limits: 42 | cpus: '1' 43 | memory: '1.7GB' 44 | 45 | nginx: 46 | image: nginx 47 | container_name: nginx 48 | volumes: 49 | - ./nginx.conf:/etc/nginx/nginx.conf:ro 50 | ports: 51 | - 9999:9999 52 | depends_on: 53 | - api1 54 | - api2 55 | deploy: 56 | resources: 57 | limits: 58 | cpus: '0.1' 59 | memory: '0.1GB' 60 | volumes: 61 | rubygems: 62 | -------------------------------------------------------------------------------- /init.sql: -------------------------------------------------------------------------------- 1 | -- Create extensions 2 | CREATE EXTENSION IF NOT EXISTS pg_trgm; 3 | 4 | -- Create table people 5 | CREATE TABLE IF NOT EXISTS people ( 6 | id UUID PRIMARY KEY, 7 | nickname VARCHAR(32) UNIQUE NOT NULL, 8 | name VARCHAR(100) NOT NULL, 9 | birth_date DATE NOT NULL, 10 | stack VARCHAR(1024), 11 | search VARCHAR(1160) GENERATED ALWAYS AS ( 12 | LOWER(name || ' ' || nickname || ' ' || stack) 13 | ) STORED 14 | ); 15 | 16 | -- Create search index 17 | CREATE INDEX people_search_idx ON people USING GIST (search gist_trgm_ops); 18 | -------------------------------------------------------------------------------- /nginx.conf: -------------------------------------------------------------------------------- 1 | worker_processes auto; 2 | 3 | events { 4 | worker_connections 256; 5 | } 6 | 7 | http { 8 | access_log off; 9 | 10 | upstream api { 11 | server api1:3000; 12 | server api2:3000; 13 | } 14 | 15 | server { 16 | listen 9999; 17 | 18 | location / { 19 | proxy_pass http://api; 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /nginx.prod.conf: -------------------------------------------------------------------------------- 1 | worker_processes auto; 2 | 3 | events { 4 | worker_connections 512; 5 | } 6 | 7 | http { 8 | access_log off; 9 | 10 | upstream api { 11 | server api1:3000; 12 | server api2:3000; 13 | } 14 | 15 | server { 16 | listen 9999; 17 | 18 | location / { 19 | proxy_pass http://api; 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /postgresql.conf: -------------------------------------------------------------------------------- 1 | # ----------------------------- 2 | # PostgreSQL configuration file 3 | # ----------------------------- 4 | 5 | listen_addresses = '*' 6 | 7 | # RESOURCE USAGE 8 | max_connections = 30 9 | 10 | # QUERY TUNING 11 | random_page_cost = 1.1 12 | effective_io_concurrency = 30 13 | -------------------------------------------------------------------------------- /postgresql.prod.conf: -------------------------------------------------------------------------------- 1 | # ----------------------------- 2 | # PostgreSQL configuration file 3 | # ----------------------------- 4 | 5 | listen_addresses = '*' 6 | 7 | # RESOURCE USAGE 8 | #max_connections = 200 9 | #shared_buffers = 256MB 10 | #maintenance_work_mem = 256MB 11 | # 12 | ### WRITE AHEAD LOG 13 | #wal_buffers = 32MB 14 | #synchronous_commit = off 15 | #fsync = off 16 | #full_page_writes = off 17 | # 18 | ### QUERY TUNING 19 | #random_page_cost = 3.1 20 | #effective_io_concurrency = 2 21 | #checkpoint_completion_target = 0.9 22 | #checkpoint_timeout = 10min 23 | #autovacuum = off 24 | --------------------------------------------------------------------------------