From cbbb6ddbe45fc646e47f2ddc98354ad5c798f1ef Mon Sep 17 00:00:00 2001 From: "Nikita M." Date: Wed, 20 May 2026 10:39:43 +0100 Subject: [PATCH 1/7] refactor: consolidate RubyAtScale module and namespace cache stampede --- .rubocop.yml | 10 +++ config/database.yml | 15 +++- lib/ruby_at_scale.rb | 50 +++++++----- lib/ruby_at_scale/cache_stampede.rb | 5 +- lib/ruby_at_scale/cache_stampede/cache.rb | 23 +++--- lib/ruby_at_scale/cache_stampede/solution.rb | 80 ++++++++++---------- lib/ruby_at_scale/database.rb | 19 ----- 7 files changed, 113 insertions(+), 89 deletions(-) delete mode 100644 lib/ruby_at_scale/database.rb diff --git a/.rubocop.yml b/.rubocop.yml index 2b20779..caca474 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,5 +1,11 @@ AllCops: NewCops: enable + Exclude: + - '.gems/**/*' + - '.direnv/**/*' + - 'vendor/**/*' + - '.bundle/**/*' + Style/Documentation: Enabled: false @@ -7,5 +13,9 @@ Style/Documentation: Metrics/MethodLength: Enabled: false +Metrics/BlockLength: + Exclude: + - 'spec/**/*' + Lint/UnusedMethodArgument: Enabled: false diff --git a/config/database.yml b/config/database.yml index a72795b..bdb60a8 100644 --- a/config/database.yml +++ b/config/database.yml @@ -1,6 +1,13 @@ -development: +test: adapter: postgresql host: localhost - database: ruby_at_scale - username: <%= ENV.fetch('PGUSER', 'postgres') %> - password: <%= ENV.fetch('PGPASSWORD', '') %> + database: ruby_at_scale_test + username: postgres + password: + +production: + adapter: postgresql + host: localhost + database: ruby_at_scale_production + username: postgres + password: diff --git a/lib/ruby_at_scale.rb b/lib/ruby_at_scale.rb index 2232883..4126765 100644 --- a/lib/ruby_at_scale.rb +++ b/lib/ruby_at_scale.rb @@ -1,31 +1,47 @@ # frozen_string_literal: true require 'bundler/setup' -require 'sinatra' +require 'sinatra/base' +require 'sinatra/activerecord' require 'redis' module RubyAtScale - module CacheStampede - def self.db_connection - @db_connection ||= begin - RubyAtScale::Database.establish_connection - ActiveRecord::Base.connection - end - end + VERSION = '0.1.1' - def self.redis - @redis ||= Redis.new + def self.db_connection + @db_connection ||= begin + ActiveRecord::Base.establish_connection(database_config) + ActiveRecord::Base.connection end + end - def self.cache - @cache ||= Cache.new(redis) - end + def self.env + ENV.fetch('RACK_ENV', 'production') + end - def self.expensive_query - db_connection.execute('SELECT pg_sleep(10), COUNT(*) FROM events').to_a.to_s - end + def self.redis + @redis ||= Redis.new + end + + def self.version + VERSION + end + + def self.database_config + YAML.safe_load( + ERB.new( + File.read( + database_config_path + ) + ).result + )[env] + end + + def self.database_config_path + File.expand_path('../config/database.yml', __dir__) end end -require_relative 'ruby_at_scale/database' require_relative 'ruby_at_scale/cache_stampede' +require_relative 'ruby_at_scale/rate_limiter' +require_relative 'ruby_at_scale/controllers' diff --git a/lib/ruby_at_scale/cache_stampede.rb b/lib/ruby_at_scale/cache_stampede.rb index 119af8d..bdc5f75 100644 --- a/lib/ruby_at_scale/cache_stampede.rb +++ b/lib/ruby_at_scale/cache_stampede.rb @@ -1,5 +1,8 @@ # frozen_string_literal: true -module CacheStampede; end +module RubyAtScale + module CacheStampede; end +end require_relative 'cache_stampede/cache' +require_relative 'cache_stampede/solution' diff --git a/lib/ruby_at_scale/cache_stampede/cache.rb b/lib/ruby_at_scale/cache_stampede/cache.rb index f41c3cb..9418d03 100644 --- a/lib/ruby_at_scale/cache_stampede/cache.rb +++ b/lib/ruby_at_scale/cache_stampede/cache.rb @@ -24,17 +24,20 @@ # - Lock should timeout after 5 seconds (in case builder crashes) # - Must be safe under 100 concurrent processes -class Cache - LOCK_TTL = 15 - POLL_INTERVAL = 0.05 +module RubyAtScale + module CacheStampede + class Cache + LOCK_TTL = 15 + POLL_INTERVAL = 0.05 - def initialize(redis = Redis.new) - @redis = redis - end + def initialize(redis = Redis.new) + @redis = redis + end - # Implement this method - def fetch(_key, ttl: 60) - # Your solution here - yield + def fetch(_key, ttl: 60) + # Your solution here + yield + end + end end end diff --git a/lib/ruby_at_scale/cache_stampede/solution.rb b/lib/ruby_at_scale/cache_stampede/solution.rb index 2b6cb92..8f568ef 100644 --- a/lib/ruby_at_scale/cache_stampede/solution.rb +++ b/lib/ruby_at_scale/cache_stampede/solution.rb @@ -24,54 +24,58 @@ # - Lock should timeout after 5 seconds (in case builder crashes) # - Must be safe under 100 concurrent processes -class Solution - LOCK_TTL = 15 - POLL_INTERVAL = 0.05 +module RubyAtScale + module CacheStampede + class Solution + LOCK_TTL = 15 + POLL_INTERVAL = 0.05 - def initialize(redis = Redis.new) - @redis = redis - end + def initialize(redis = Redis.new) + @redis = redis + end - def fetch(key, ttl: 60) - cached = cached_value(key) - lock_key = "#{key}:lock" + def fetch(key, ttl: 60) + cached = cached_value(key) + lock_key = "#{key}:lock" - return cached unless cached.to_s.empty? + return cached unless cached.to_s.empty? - if lock!(lock_key) - result = yield - redis.set(key, result, ex: ttl) - redis.del(lock_key) - result - else - await_redis_cache(key) - end - end + if lock!(lock_key) + result = yield + redis.set(key, result, ex: ttl) + redis.del(lock_key) + result + else + await_redis_cache(key) + end + end - private + private - attr_reader :redis + attr_reader :redis - def await_redis_cache(key) - deadline = waiting_deadline + def await_redis_cache(key) + deadline = waiting_deadline - loop do - sleep(POLL_INTERVAL) - value = redis.get(key) - return value if value - break if Time.now > deadline - end - end + loop do + sleep(POLL_INTERVAL) + value = redis.get(key) + return value if value + break if Time.now > deadline + end + end - def waiting_deadline - Time.now + LOCK_TTL - end + def waiting_deadline + Time.now + LOCK_TTL + end - def lock!(lock_key) - redis.set(lock_key, true, nx: true, ex: LOCK_TTL) - end + def lock!(lock_key) + redis.set(lock_key, true, nx: true, ex: LOCK_TTL) + end - def cached_value(key) - redis.get(key) + def cached_value(key) + redis.get(key) + end + end end end diff --git a/lib/ruby_at_scale/database.rb b/lib/ruby_at_scale/database.rb deleted file mode 100644 index 11faab8..0000000 --- a/lib/ruby_at_scale/database.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -require 'active_record' - -module RubyAtScale - module Database - def self.establish_connection - ActiveRecord::Base.establish_connection(config) - end - - def self.config - @config ||= YAML.safe_load(ERB.new(File.read(config_path)).result)['development'] - end - - def self.config_path - File.expand_path('../../config/database.yml', __dir__) - end - end -end From 27958d61a4fc2f5711425f8530d0bc2d01847f41 Mon Sep 17 00:00:00 2001 From: "Nikita M." Date: Wed, 20 May 2026 10:40:00 +0100 Subject: [PATCH 2/7] feat: add sinatra-activerecord with rake tasks and migrations --- Gemfile | 8 ++- Gemfile.lock | 63 +++++++++++++++++++--- Rakefile | 6 +++ bin/setup | 31 ----------- db/migrate/20260520000001_create_events.rb | 11 ++++ db/schema.rb | 25 +++++++++ 6 files changed, 105 insertions(+), 39 deletions(-) create mode 100644 Rakefile delete mode 100755 bin/setup create mode 100644 db/migrate/20260520000001_create_events.rb create mode 100644 db/schema.rb diff --git a/Gemfile b/Gemfile index b341bda..33b10be 100644 --- a/Gemfile +++ b/Gemfile @@ -7,11 +7,15 @@ ruby '4.0.3' gem 'activerecord' gem 'pg' gem 'puma' +gem 'rake' gem 'redis' gem 'sinatra' +gem 'sinatra-activerecord' -group :development do - gem 'rubocop' +group :development, :test do + gem 'rack-test' + gem 'rspec' gem 'ruby-lsp' gem 'solargraph' + gem 'standard', '>= 1.35.1' end diff --git a/Gemfile.lock b/Gemfile.lock index a35fb25..6ef38b5 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -50,7 +50,7 @@ GEM observer (0.1.2) open3 (0.2.1) ostruct (0.6.3) - parallel (2.1.0) + parallel (1.28.0) parser (3.3.11.1) ast (~> 2.4.1) racc @@ -68,7 +68,10 @@ GEM rack-session (2.1.2) base64 (>= 0.1.0) rack (>= 3.0.0) + rack-test (2.2.0) + rack (>= 1.3) rainbow (3.1.1) + rake (13.4.2) rbs (4.0.2) logger prism (>= 1.6.0) @@ -81,11 +84,24 @@ GEM reverse_markdown (3.0.2) nokogiri rexml (3.4.4) - rubocop (1.86.2) + rspec (3.13.2) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.6) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-mocks (3.13.8) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-support (3.13.7) + rubocop (1.84.2) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) - parallel (>= 1.10) + parallel (~> 1.10) parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 2.9.3, < 3.0) @@ -95,6 +111,10 @@ GEM rubocop-ast (1.49.1) parser (>= 3.3.7.2) prism (~> 1.7) + rubocop-performance (1.26.1) + lint_roller (~> 1.1) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.47.1, < 2.0) ruby-lsp (0.26.9) language_server-protocol (~> 3.17.0) prism (>= 1.2, < 2.0) @@ -108,6 +128,9 @@ GEM rack-protection (= 4.2.1) rack-session (>= 2.0.0, < 3) tilt (~> 2.0) + sinatra-activerecord (2.0.28) + activerecord (>= 4.1) + sinatra (>= 1.0) solargraph (0.59.0) ast (~> 2.4.3) backport (~> 1.2) @@ -131,6 +154,18 @@ GEM yard (~> 0.9, >= 0.9.24) yard-activesupport-concern (~> 0.0) yard-solargraph (~> 0.1) + standard (1.54.0) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.0) + rubocop (~> 1.84.0) + standard-custom (~> 1.0.0) + standard-performance (~> 1.8) + standard-custom (1.0.2) + lint_roller (~> 1.0) + rubocop (~> 1.50) + standard-performance (1.9.0) + lint_roller (~> 1.1) + rubocop-performance (~> 1.26.0) thor (1.5.0) tilt (2.7.0) timeout (0.6.1) @@ -154,11 +189,15 @@ DEPENDENCIES activerecord pg puma + rack-test + rake redis - rubocop + rspec ruby-lsp sinatra + sinatra-activerecord solargraph + standard (>= 1.35.1) CHECKSUMS activemodel (8.1.3) sha256=90c05cbe4cef3649b8f79f13016191ea94c4525ce4a5c0fb7ef909c4b91c8219 @@ -188,7 +227,7 @@ CHECKSUMS observer (0.1.2) sha256=d8a3107131ba661138d748e7be3dbafc0d82e732fffba9fccb3d7829880950ac open3 (0.2.1) sha256=8e2d7d2113526351201438c1aa35c8139f0141c9e8913baa007c898973bf3952 ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912 - parallel (2.1.0) sha256=b35258865c2e31134c5ecb708beaaf6772adf9d5efae28e93e99260877b09356 + parallel (1.28.0) sha256=33e6de1484baf2524792d178b0913fc8eb94c628d6cfe45599ad4458c638c970 parser (3.3.11.1) sha256=d17ace7aabe3e72c3cc94043714be27cc6f852f104d81aa284c2281aecc65d54 pg (1.6.3) sha256=1388d0563e13d2758c1089e35e973a3249e955c659592d10e5b77c468f628a99 pg (1.6.3-x86_64-linux) sha256=5d9e188c8f7a0295d162b7b88a768d8452a899977d44f3274d1946d67920ae8d @@ -198,20 +237,32 @@ CHECKSUMS rack (3.2.6) sha256=5ed78e1f73b2e25679bec7d45ee2d4483cc4146eb1be0264fc4d94cb5ef212c2 rack-protection (4.2.1) sha256=cf6e2842df8c55f5e4d1a4be015e603e19e9bc3a7178bae58949ccbb58558bac rack-session (2.1.2) sha256=595434f8c0c3473ae7d7ac56ecda6cc6dfd9d37c0b2b5255330aa1576967ffe8 + rack-test (2.2.0) sha256=005a36692c306ac0b4a9350355ee080fd09ddef1148a5f8b2ac636c720f5c463 rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a + rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701 rbs (4.0.2) sha256=af75671e66cd03434cc546622741ebf83f6197ec4328375805306330bf78ef25 redis (5.4.1) sha256=b5e675b57ad22b15c9bcc765d5ac26f60b675408af916d31527af9bd5a81faae redis-client (0.29.0) sha256=0c65bf1f8f6dca22063ddb085c0bb2054feef6f03a84869f4161b18a9a15bea3 regexp_parser (2.12.0) sha256=35a916a1d63190ab5c9009457136ae5f3c0c7512d60291d0d1378ba18ce08ebb reverse_markdown (3.0.2) sha256=818ebb92ce39dbb1a291690dd1ec9a6d62530d4725296b17e9c8f668f9a5b8af rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142 - rubocop (1.86.2) sha256=bb2e97f635eda42c448f2588f4a6ff78f221b8bdfdf65b1e9b07fbd57521b45d + rspec (3.13.2) sha256=206284a08ad798e61f86d7ca3e376718d52c0bc944626b2349266f239f820587 + rspec-core (3.13.6) sha256=a8823c6411667b60a8bca135364351dda34cd55e44ff94c4be4633b37d828b2d + rspec-expectations (3.13.5) sha256=33a4d3a1d95060aea4c94e9f237030a8f9eae5615e9bd85718fe3a09e4b58836 + rspec-mocks (3.13.8) sha256=086ad3d3d17533f4237643de0b5c42f04b66348c28bf6b9c2d3f4a3b01af1d47 + rspec-support (3.13.7) sha256=0640e5570872aafefd79867901deeeeb40b0c9875a36b983d85f54fb7381c47c + rubocop (1.84.2) sha256=5692cea54168f3dc8cb79a6fe95c5424b7ea893c707ad7a4307b0585e88dbf5f rubocop-ast (1.49.1) sha256=4412f3ee70f6fe4546cc489548e0f6fcf76cafcfa80fa03af67098ffed755035 + rubocop-performance (1.26.1) sha256=cd19b936ff196df85829d264b522fd4f98b6c89ad271fa52744a8c11b8f71834 ruby-lsp (0.26.9) sha256=33a01c001c00a76b4e821efc04ed7572983430f31ca5d6f3e343d0b6ccab4129 ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 sinatra (4.2.1) sha256=b7aeb9b11d046b552972ade834f1f9be98b185fa8444480688e3627625377080 + sinatra-activerecord (2.0.28) sha256=99f352c2dfa244d02b4f877efbe00135360b758390b8bb7bc2d4d91171c93811 solargraph (0.59.0) sha256=229845d4ce99b6259e3d7fd9c8238f9cd74c3056e59a4a16fb1525d6a790dad3 + standard (1.54.0) sha256=7a4b08f83d9893083c8f03bc486f0feeb6a84d48233b40829c03ef4767ea0100 + standard-custom (1.0.2) sha256=424adc84179a074f1a2a309bb9cf7cd6bfdb2b6541f20c6bf9436c0ba22a652b + standard-performance (1.9.0) sha256=49483d31be448292951d80e5e67cdcb576c2502103c7b40aec6f1b6e9c88e3f2 thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73 tilt (2.7.0) sha256=0d5b9ba69f6a36490c64b0eee9f6e9aad517e20dcc848800a06eb116f08c6ab3 timeout (0.6.1) sha256=78f57368a7e7bbadec56971f78a3f5ecbcfb59b7fcbb0a3ed6ddc08a5094accb diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..eced370 --- /dev/null +++ b/Rakefile @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +ENV['RACK_ENV'] ||= 'production' + +require_relative 'lib/ruby_at_scale' +require 'sinatra/activerecord/rake' diff --git a/bin/setup b/bin/setup deleted file mode 100755 index d4fa8da..0000000 --- a/bin/setup +++ /dev/null @@ -1,31 +0,0 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true - -require_relative '../lib/ruby_at_scale' - -config = RubyAtScale::Database.config - -begin - ActiveRecord::Base.establish_connection(config.merge('database' => 'postgres')) - ActiveRecord::Base.connection.create_database(config['database']) - puts 'Database created.' -rescue ActiveRecord::DatabaseAlreadyExists - puts 'Database already exists.' -end - -ActiveRecord::Base.establish_connection(config) - -if ActiveRecord::Base.connection.table_exists?(:events) - puts 'Table already exists, skipping.' -else - ActiveRecord::Schema.define do - create_table :events do |t| - t.string :event_type - t.decimal :amount, precision: 10, scale: 2 - t.timestamps - end - end - puts 'Created events table.' -end - -puts 'Setup complete.' diff --git a/db/migrate/20260520000001_create_events.rb b/db/migrate/20260520000001_create_events.rb new file mode 100644 index 0000000..c230ef1 --- /dev/null +++ b/db/migrate/20260520000001_create_events.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class CreateEvents < ActiveRecord::Migration[7.1] + def change + create_table :events do |t| + t.string :event_type + t.decimal :amount, precision: 10, scale: 2 + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb new file mode 100644 index 0000000..3138cbc --- /dev/null +++ b/db/schema.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +# This file is auto-generated from the current state of the database. Instead +# of editing this file, please use the migrations feature of Active Record to +# incrementally modify your database, and then regenerate this schema definition. +# +# This file is the source Rails uses to define your schema when running `bin/rails +# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to +# be faster and is potentially less error prone than running all of your +# migrations from scratch. Old migrations may fail to apply correctly if those +# migrations use external dependencies or application code. +# +# It's strongly recommended that you check this file into your version control system. + +ActiveRecord::Schema[8.1].define(version: 20_260_520_000_001) do + # These are extensions that must be enabled in order to support this database + enable_extension 'pg_catalog.plpgsql' + + create_table 'events', force: :cascade do |t| + t.decimal 'amount', precision: 10, scale: 2 + t.datetime 'created_at', null: false + t.string 'event_type' + t.datetime 'updated_at', null: false + end +end From 29ca4796541973d154110dd3b2da742512e2fd97 Mon Sep 17 00:00:00 2001 From: "Nikita M." Date: Wed, 20 May 2026 10:40:12 +0100 Subject: [PATCH 3/7] feat: add modular Sinatra controllers with Rack routing --- config.ru | 14 ++--------- config/routes.rb | 4 ++++ lib/ruby_at_scale/controllers.rb | 9 ++++++++ .../controllers/application_controller.rb | 21 +++++++++++++++++ .../controllers/rate_limit_controller.rb | 23 +++++++++++++++++++ .../controllers/reports_controller.rb | 19 +++++++++++++++ 6 files changed, 78 insertions(+), 12 deletions(-) create mode 100644 config/routes.rb create mode 100644 lib/ruby_at_scale/controllers.rb create mode 100644 lib/ruby_at_scale/controllers/application_controller.rb create mode 100644 lib/ruby_at_scale/controllers/rate_limit_controller.rb create mode 100644 lib/ruby_at_scale/controllers/reports_controller.rb diff --git a/config.ru b/config.ru index 70aed3c..14cf348 100644 --- a/config.ru +++ b/config.ru @@ -2,15 +2,5 @@ require_relative 'lib/ruby_at_scale' -get '/report' do - content_type :json - - result = RubyAtScale::CacheStampede.cache.fetch('expensive_report', ttl: 60) do - RubyAtScale::CacheStampede.redis.incr('stampede:query_count') - RubyAtScale::CacheStampede.expensive_query - end - - { pid: Process.pid, result_size: result.to_s.length }.to_json -end - -run Sinatra::Application +map('/report') { run ReportsController } +map('/limited') { run RateLimitController } diff --git a/config/routes.rb b/config/routes.rb new file mode 100644 index 0000000..32683c4 --- /dev/null +++ b/config/routes.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +map('/report') { run ReportsController } +map('/limited') { run RateLimitController } diff --git a/lib/ruby_at_scale/controllers.rb b/lib/ruby_at_scale/controllers.rb new file mode 100644 index 0000000..5c8c85e --- /dev/null +++ b/lib/ruby_at_scale/controllers.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module RubyAtScale + module Controllers; end +end + +require_relative 'controllers/application_controller' +require_relative 'controllers/reports_controller' +require_relative 'controllers/rate_limit_controller' diff --git a/lib/ruby_at_scale/controllers/application_controller.rb b/lib/ruby_at_scale/controllers/application_controller.rb new file mode 100644 index 0000000..120a491 --- /dev/null +++ b/lib/ruby_at_scale/controllers/application_controller.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'sinatra/base' + +module RubyAtScale + module Controllers + class ApplicationController < Sinatra::Base + configure do + set :show_exceptions, false + end + + helpers do + def json_response(hash, status_code = 200) + content_type :json + status status_code + hash.to_json + end + end + end + end +end diff --git a/lib/ruby_at_scale/controllers/rate_limit_controller.rb b/lib/ruby_at_scale/controllers/rate_limit_controller.rb new file mode 100644 index 0000000..796bf4b --- /dev/null +++ b/lib/ruby_at_scale/controllers/rate_limit_controller.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module RubyAtScale + module Controllers + class RateLimitController < ApplicationController + LIMITER = RubyAtScale::RateLimiter::SlidingWindow.new( + redis: Redis.new, + max_requests: 10, + window_seconds: 60 + ) + + get '/' do + client_id = request.ip + + if LIMITER.allow?(client_id) + json_response(pid: Process.pid, status: 'allowed') + else + json_response({ pid: Process.pid, status: 'rate_limited' }, 429) + end + end + end + end +end diff --git a/lib/ruby_at_scale/controllers/reports_controller.rb b/lib/ruby_at_scale/controllers/reports_controller.rb new file mode 100644 index 0000000..ccc8c52 --- /dev/null +++ b/lib/ruby_at_scale/controllers/reports_controller.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module RubyAtScale + module Controllers + class ReportsController < ApplicationController + CACHE = RubyAtScale::CacheStampede::Cache.new(RubyAtScale.redis) + + get '/' do + result = CACHE.fetch('expensive_report', ttl: 60) do + RubyAtScale.redis.incr('stampede:query_count') + + RubyAtScale.db_connection.execute('SELECT pg_sleep(10), COUNT(*) FROM events').to_a.to_s + end + + json_response(pid: Process.pid, result_size: result.to_s.length) + end + end + end +end From 4ae0bb7531140528383ee501fad7c4abce327078 Mon Sep 17 00:00:00 2001 From: "Nikita M." Date: Wed, 20 May 2026 11:20:28 +0100 Subject: [PATCH 4/7] feat: add rate limiter, RSpec specs, system tests, and controller fixes - Add atomic sliding window rate limiter (Lua script) - Add RSpec specs for controllers, cache stampede, and rate limiter - Migrate system tests to spec/system/bin/ with shared helpers - Fix controller routing to use full namespace in config.ru - Fix Puma environment config for database.yml compatibility - Update README with setup and test instructions --- .rspec | 1 + README.md | 17 ++- config.ru | 4 +- config/puma.rb | 2 + config/routes.rb | 4 +- .../controllers/rate_limit_controller.rb | 2 +- .../controllers/reports_controller.rb | 6 +- lib/ruby_at_scale/rate_limiter.rb | 6 + .../rate_limiter/sliding_window.rb | 28 +++++ lib/ruby_at_scale/rate_limiter/solution.rb | 46 ++++++++ .../cache_stampede/solution_spec.rb | 86 ++++++++++++++ .../application_controller_spec.rb | 46 ++++++++ .../controllers/rate_limit_controller_spec.rb | 59 ++++++++++ .../controllers/reports_controller_spec.rb | 43 +++++++ .../rate_limiter/solution_spec.rb | 68 ++++++++++++ spec/ruby_at_scale_spec.rb | 52 +++++++++ spec/spec_helper.rb | 105 ++++++++++++++++++ spec/system/bin/cache_stampede_test | 44 ++++++++ spec/system/bin/helpers/cli | 41 +++++++ spec/system/bin/helpers/colors | 8 ++ spec/system/bin/helpers/redis | 9 ++ spec/system/bin/rate_limiter_test | 69 ++++++++++++ spec/system/cache_stampede_spec.rb | 13 +++ spec/system/rate_limiter_spec.rb | 13 +++ test/ruby_at_scale/cache_stampede_test | 30 ----- 25 files changed, 763 insertions(+), 39 deletions(-) create mode 100644 .rspec create mode 100644 lib/ruby_at_scale/rate_limiter.rb create mode 100644 lib/ruby_at_scale/rate_limiter/sliding_window.rb create mode 100644 lib/ruby_at_scale/rate_limiter/solution.rb create mode 100644 spec/ruby_at_scale/cache_stampede/solution_spec.rb create mode 100644 spec/ruby_at_scale/controllers/application_controller_spec.rb create mode 100644 spec/ruby_at_scale/controllers/rate_limit_controller_spec.rb create mode 100644 spec/ruby_at_scale/controllers/reports_controller_spec.rb create mode 100644 spec/ruby_at_scale/rate_limiter/solution_spec.rb create mode 100644 spec/ruby_at_scale_spec.rb create mode 100644 spec/spec_helper.rb create mode 100755 spec/system/bin/cache_stampede_test create mode 100644 spec/system/bin/helpers/cli create mode 100644 spec/system/bin/helpers/colors create mode 100644 spec/system/bin/helpers/redis create mode 100755 spec/system/bin/rate_limiter_test create mode 100644 spec/system/cache_stampede_spec.rb create mode 100644 spec/system/rate_limiter_spec.rb delete mode 100755 test/ruby_at_scale/cache_stampede_test diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..c99d2e7 --- /dev/null +++ b/.rspec @@ -0,0 +1 @@ +--require spec_helper diff --git a/README.md b/README.md index 3a0d26e..84b48b0 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Ruby developers who want to go beyond CRUD and learn how to handle high traffic, | Problem | Description | Difficulty | |---------|-------------|------------| | [Cache Stampede](lib/ruby_at_scale/cache_stampede/) | Prevent 1000 concurrent requests from hammering your DB when cache expires | Medium | +| [Rate Limiter](lib/ruby_at_scale/rate_limiter/) | Atomic sliding window rate limiting under high concurrency | Medium | ## How it works @@ -23,7 +24,13 @@ Ruby developers who want to go beyond CRUD and learn how to handle high traffic, ```bash bundle install -bin/setup +``` + +Update `config/database.yml` with your PostgreSQL credentials, then: + +```bash +rake db:create +rake db:migrate ``` ## Run @@ -35,8 +42,12 @@ bin/server ## Test ```bash -# Cache Stampede -test/ruby_at_scale/cache_stampede_test +# Unit/Integration specs +rspec --tag ~system + +# System tests (interactive, requires running PostgreSQL and Redis) +spec/system/bin/cache_stampede_test +spec/system/bin/rate_limiter_test ``` ## Dependencies diff --git a/config.ru b/config.ru index 14cf348..f21f19e 100644 --- a/config.ru +++ b/config.ru @@ -2,5 +2,5 @@ require_relative 'lib/ruby_at_scale' -map('/report') { run ReportsController } -map('/limited') { run RateLimitController } +map('/report') { run RubyAtScale::Controllers::ReportsController } +map('/limited') { run RubyAtScale::Controllers::RateLimitController } diff --git a/config/puma.rb b/config/puma.rb index 1f5491f..195f19b 100644 --- a/config/puma.rb +++ b/config/puma.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +environment ENV.fetch('RACK_ENV', 'production') + workers 4 threads_count = 5 threads threads_count, threads_count diff --git a/config/routes.rb b/config/routes.rb index 32683c4..481aac2 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,4 +1,4 @@ # frozen_string_literal: true -map('/report') { run ReportsController } -map('/limited') { run RateLimitController } +map('/report') { run RubyAtScale::Controllers::ReportsController } +map('/limited') { run RubyAtScale::Controllers::RateLimitController } diff --git a/lib/ruby_at_scale/controllers/rate_limit_controller.rb b/lib/ruby_at_scale/controllers/rate_limit_controller.rb index 796bf4b..35d3d00 100644 --- a/lib/ruby_at_scale/controllers/rate_limit_controller.rb +++ b/lib/ruby_at_scale/controllers/rate_limit_controller.rb @@ -4,7 +4,7 @@ module RubyAtScale module Controllers class RateLimitController < ApplicationController LIMITER = RubyAtScale::RateLimiter::SlidingWindow.new( - redis: Redis.new, + redis: RubyAtScale.redis, max_requests: 10, window_seconds: 60 ) diff --git a/lib/ruby_at_scale/controllers/reports_controller.rb b/lib/ruby_at_scale/controllers/reports_controller.rb index ccc8c52..88f2df8 100644 --- a/lib/ruby_at_scale/controllers/reports_controller.rb +++ b/lib/ruby_at_scale/controllers/reports_controller.rb @@ -6,12 +6,16 @@ class ReportsController < ApplicationController CACHE = RubyAtScale::CacheStampede::Cache.new(RubyAtScale.redis) get '/' do + $stdout.puts "[#{Process.pid}] Request received" + result = CACHE.fetch('expensive_report', ttl: 60) do + $stdout.puts "[#{Process.pid}] Cache MISS — executing DB query" RubyAtScale.redis.incr('stampede:query_count') - RubyAtScale.db_connection.execute('SELECT pg_sleep(10), COUNT(*) FROM events').to_a.to_s + ActiveRecord::Base.connection.execute('SELECT pg_sleep(5), COUNT(*) FROM events').to_a.to_s end + $stdout.puts "[#{Process.pid}] Responding (result_size=#{result.to_s.length})" json_response(pid: Process.pid, result_size: result.to_s.length) end end diff --git a/lib/ruby_at_scale/rate_limiter.rb b/lib/ruby_at_scale/rate_limiter.rb new file mode 100644 index 0000000..c800b61 --- /dev/null +++ b/lib/ruby_at_scale/rate_limiter.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module RateLimiter; end + +require_relative 'rate_limiter/sliding_window' +require_relative 'rate_limiter/solution' diff --git a/lib/ruby_at_scale/rate_limiter/sliding_window.rb b/lib/ruby_at_scale/rate_limiter/sliding_window.rb new file mode 100644 index 0000000..b3c8b27 --- /dev/null +++ b/lib/ruby_at_scale/rate_limiter/sliding_window.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require 'redis' + +# Naive rate limiter - uses simple GET/INCR with TTL (fixed window). +# This has race conditions under concurrent load: +# +# - Multiple workers can read count=9, all increment, exceeding the limit +# - Fixed window allows burst at boundary (2x limit in 1 second) + +module RubyAtScale + module RateLimiter + class SlidingWindow + KEY_PREFIX = 'rate_limit:' + + def initialize(redis: Redis.new, max_requests: 10, window_seconds: 60) + @redis = redis + @max_requests = max_requests + @window_seconds = window_seconds + end + + def allow?(client_id) + # your solution here + true + end + end + end +end diff --git a/lib/ruby_at_scale/rate_limiter/solution.rb b/lib/ruby_at_scale/rate_limiter/solution.rb new file mode 100644 index 0000000..0bea793 --- /dev/null +++ b/lib/ruby_at_scale/rate_limiter/solution.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'redis' + +module RubyAtScale + module RateLimiter + class Solution + SCRIPT = <<~LUA + local key = KEYS[1] + local window = tonumber(ARGV[1]) + local max = tonumber(ARGV[2]) + local now = tonumber(ARGV[3]) + local member = ARGV[4] + + redis.call('ZREMRANGEBYSCORE', key, '-inf', now - window) + local count = redis.call('ZCARD', key) + + if count < max then + redis.call('ZADD', key, now, member) + redis.call('EXPIRE', key, math.ceil(window) + 1) + return 1 + else + return 0 + end + LUA + + KEY_PREFIX = 'rate_limit' + + def initialize(redis: Redis.new, max_requests: 10, window_seconds: 60) + @redis = redis + @max_requests = max_requests + @window_seconds = window_seconds + end + + def allow?(client_id) + now = Time.now.to_f + result = @redis.eval( + SCRIPT, + keys: ["#{KEY_PREFIX}:#{client_id}"], + argv: [@window_seconds, @max_requests, now, "#{now}:#{rand}"] + ) + result == 1 + end + end + end +end diff --git a/spec/ruby_at_scale/cache_stampede/solution_spec.rb b/spec/ruby_at_scale/cache_stampede/solution_spec.rb new file mode 100644 index 0000000..abbeb87 --- /dev/null +++ b/spec/ruby_at_scale/cache_stampede/solution_spec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +RSpec.describe RubyAtScale::CacheStampede::Solution do + + let(:key) { "test:cache:#{SecureRandom.hex(4)}" } + let(:redis) { Redis.new } + + after { redis.del(key, "#{key}:lock") } + + subject { described_class.new(redis) } + + describe '#initialize' do + it 'assigns redis to @redis' do + expect(subject.instance_variable_get(:@redis)).to an_instance_of(Redis) + end + end + + describe '#fetch' do + it 'returns computed value on cache miss' do + result = subject.fetch(key, ttl: 10) { 'computed' } + expect(result).to eq('computed') + end + + it 'returns cached value on cache hit' do + subject.fetch(key, ttl: 10) { 'first' } + + result = subject.fetch(key, ttl: 10) { 'second' } + + expect(result).to eq('first') + end + + it 'stores value in redis with ttl' do + subject.fetch(key, ttl: 5) { 'stored' } + + expect(redis.get(key)).to eq('stored') + + expect(redis.ttl(key)).to be_between(1, 5) + end + + it 'executes block only once for concurrent requests' do + call_count = Concurrent::AtomicFixnum.new(0) + + threads = 10.times.map do + Thread.new do + subject.fetch(key, ttl: 10) do + call_count.increment + sleep 0.1 + 'result' + end + end + end + + threads.each(&:join) + expect(call_count.value).to eq(1) + end + + it 'waiting threads get the cached value' do + results = Concurrent::Array.new + + threads = 10.times.map do + Thread.new do + result = subject.fetch(key, ttl: 10) do + sleep 0.1 + 'shared_value' + end + results << result + end + end + + threads.each(&:join) + expect(results).to all(eq('shared_value')) + end + + it 'releases lock after computation' do + subject.fetch(key, ttl: 10) { 'done' } + expect(redis.get("#{key}:lock")).to be_nil + end + + it 'lock has TTL to prevent deadlock' do + redis.set("#{key}:lock", true, nx: true, ex: 1) + sleep 1.1 + result = subject.fetch(key, ttl: 10) { 'recovered' } + expect(result).to eq('recovered') + end + end +end diff --git a/spec/ruby_at_scale/controllers/application_controller_spec.rb b/spec/ruby_at_scale/controllers/application_controller_spec.rb new file mode 100644 index 0000000..caa1219 --- /dev/null +++ b/spec/ruby_at_scale/controllers/application_controller_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +RSpec.describe RubyAtScale::Controllers::ApplicationController, type: :controller do + let(:test_app) do + Class.new(described_class) do + get '/test' do + json_response(message: 'hello') + end + + get '/error' do + json_response({ error: 'too many' }, 429) + end + end + end + + def app + test_app + end + + describe '#json_response' do + it 'returns json content type' do + get '/test' + + expect(last_response.content_type).to include('application/json') + end + + it 'returns serialized json body' do + get '/test' + + body = JSON.parse(last_response.body) + expect(body['message']).to eq('hello') + end + + it 'defaults to 200 status' do + get '/test' + + expect(last_response.status).to eq(200) + end + + it 'supports custom status codes' do + get '/error' + + expect(last_response.status).to eq(429) + end + end +end diff --git a/spec/ruby_at_scale/controllers/rate_limit_controller_spec.rb b/spec/ruby_at_scale/controllers/rate_limit_controller_spec.rb new file mode 100644 index 0000000..d24130c --- /dev/null +++ b/spec/ruby_at_scale/controllers/rate_limit_controller_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +RSpec.describe RubyAtScale::Controllers::RateLimitController, type: :controller do + def app + described_class + end + + let(:limiter) { instance_double(RubyAtScale::RateLimiter::SlidingWindow) } + + before do + stub_const('RubyAtScale::Controllers::RateLimitController::LIMITER', limiter) + end + + describe 'GET /' do + context 'when under limit' do + before { allow(limiter).to receive(:allow?).and_return(true) } + + it 'returns 200' do + get '/' + + expect(last_response.status).to eq(200) + end + + it 'returns allowed status in json' do + get '/' + + body = JSON.parse(last_response.body) + + expect(body['status']).to eq('allowed') + expect(body).to have_key('pid') + end + end + + context 'when over limit' do + before { allow(limiter).to receive(:allow?).and_return(false) } + + it 'returns 429' do + get '/' + + expect(last_response.status).to eq(429) + end + + it 'returns rate_limited status in json' do + get '/' + + body = JSON.parse(last_response.body) + expect(body['status']).to eq('rate_limited') + end + end + + it 'calls allow? with client ip' do + allow(limiter).to receive(:allow?).with('127.0.0.1').and_return(true) + + get '/' + + expect(limiter).to have_received(:allow?).with('127.0.0.1') + end + end +end diff --git a/spec/ruby_at_scale/controllers/reports_controller_spec.rb b/spec/ruby_at_scale/controllers/reports_controller_spec.rb new file mode 100644 index 0000000..7cf8f9f --- /dev/null +++ b/spec/ruby_at_scale/controllers/reports_controller_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +RSpec.describe RubyAtScale::Controllers::ReportsController, type: :controller do + def app + described_class + end + + let(:cache) { instance_double(RubyAtScale::CacheStampede::Cache) } + let(:redis) { instance_double(Redis) } + + before do + allow(RubyAtScale).to receive(:redis).and_return(redis) + stub_const('RubyAtScale::Controllers::ReportsController::CACHE', cache) + end + + describe 'GET /' do + it 'fetches from cache with correct key and ttl' do + allow(cache).to receive(:fetch).with('expensive_report', ttl: 60).and_return('cached_result') + + get '/' + + expect(cache).to have_received(:fetch).with('expensive_report', ttl: 60) + end + + it 'returns json with pid and result_size' do + allow(cache).to receive(:fetch).and_return('some_data') + + get '/' + + body = JSON.parse(last_response.body) + expect(body).to have_key('pid') + expect(body).to have_key('result_size') + end + + it 'returns 200 status' do + allow(cache).to receive(:fetch).and_return('data') + + get '/' + + expect(last_response.status).to eq(200) + end + end +end diff --git a/spec/ruby_at_scale/rate_limiter/solution_spec.rb b/spec/ruby_at_scale/rate_limiter/solution_spec.rb new file mode 100644 index 0000000..aff58f2 --- /dev/null +++ b/spec/ruby_at_scale/rate_limiter/solution_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +RSpec.describe RubyAtScale::RateLimiter::Solution do + + let(:redis) { Redis.new } + let(:client_id) { "test:client:#{SecureRandom.hex(4)}" } + + after { redis.del("rate_limit:#{client_id}") } + + subject { described_class.new(redis: redis, max_requests: 3, window_seconds: 2) } + + describe '#initialize' do + it 'assigns redis to @redis' do + expect(subject.instance_variable_get(:@redis)).to be(redis) + end + + it 'assigns max_requests to @max_requests' do + expect(subject.instance_variable_get(:@max_requests)).to eq(3) + end + + it 'assigns window_seconds to @window_seconds' do + expect(subject.instance_variable_get(:@window_seconds)).to eq(2) + end + end + + describe '#allow?' do + it 'allows requests under the limit' do + 3.times { expect(subject.allow?(client_id)).to be true } + end + + it 'denies requests over the limit' do + 3.times { subject.allow?(client_id) } + + expect(subject.allow?(client_id)).to be false + end + + it 'allows again after window expires' do + 3.times { subject.allow?(client_id) } + expect(subject.allow?(client_id)).to be false + + sleep 2.1 + + expect(subject.allow?(client_id)).to be true + end + + it 'tracks clients independently' do + 3.times { subject.allow?(client_id) } + + expect(subject.allow?("#{client_id}:other")).to be true + end + + it 'is atomic under concurrent access' do + allowed = Concurrent::AtomicFixnum.new(0) + + threads = Array.new(50) do + Thread.new do + r = Redis.new + limiter = described_class.new(redis: r, max_requests: 10, window_seconds: 10) + allowed.increment if limiter.allow?(client_id) + end + end + + threads.each(&:join) + + expect(allowed.value).to eq(10) + end + end +end diff --git a/spec/ruby_at_scale_spec.rb b/spec/ruby_at_scale_spec.rb new file mode 100644 index 0000000..5c0dc39 --- /dev/null +++ b/spec/ruby_at_scale_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_relative '../lib/ruby_at_scale' + +RSpec.describe RubyAtScale do + describe '.db_connection' do + it 'returns an ActiveRecord connection' do + expect(RubyAtScale.db_connection).to be_a(ActiveRecord::ConnectionAdapters::AbstractAdapter) + end + + it 'memoizes the connection' do + expect(RubyAtScale.db_connection).to be(RubyAtScale.db_connection) + end + end + + describe '.redis' do + it 'returns a Redis instance' do + expect(RubyAtScale.redis).to be_a(Redis) + end + + it 'memoizes the instance' do + expect(RubyAtScale.redis).to be(RubyAtScale.redis) + end + end + + describe '.version' do + it 'returns a version string' do + expect(RubyAtScale.version).to eq('0.1.1') + end + end + + describe '.database_config' do + it 'returns config for current environment' do + config = RubyAtScale.database_config + + expect(config['database']).to eq('ruby_at_scale_test') + expect(config['adapter']).to eq('postgresql') + expect(config['host']).to eq('localhost') + end + end + + describe '.database_config_path' do + it 'points to an existing file' do + expect(File.exist?(RubyAtScale.database_config_path)).to be true + end + + it 'returns path to database.yml' do + expect(RubyAtScale.database_config_path).to end_with('config/database.yml') + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..c38a6c4 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +ENV['RACK_ENV'] = 'test' + +require_relative '../lib/ruby_at_scale' +require 'rack/test' + +# This file was generated by the `rspec --init` command. Conventionally, all +# specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. +# The generated `.rspec` file contains `--require spec_helper` which will cause +# this file to always be loaded, without a need to explicitly require it in any +# files. +# +# Given that it is always loaded, you are encouraged to keep this file as +# light-weight as possible. Requiring heavyweight dependencies from this file +# will add to the boot time of your test suite on EVERY test run, even for an +# individual file that may not need all of that loaded. Instead, consider making +# a separate helper file that requires the additional dependencies and performs +# the additional setup, and require it from the spec files that actually need +# it. +# +# See https://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration + +RSpec.configure do |config| + config.include Rack::Test::Methods, type: :controller + # rspec-expectations config goes here. You can use an alternate + # assertion/expectation library such as wrong or the stdlib/minitest + # assertions if you prefer. + config.expect_with :rspec do |expectations| + # This option will default to `true` in RSpec 4. It makes the `description` + # and `failure_message` of custom matchers include text for helper methods + # defined using `chain`, e.g.: + # be_bigger_than(2).and_smaller_than(4).description + # # => "be bigger than 2 and smaller than 4" + # ...rather than: + # # => "be bigger than 2" + expectations.include_chain_clauses_in_custom_matcher_descriptions = true + end + + # rspec-mocks config goes here. You can use an alternate test double + # library (such as bogus or mocha) by changing the `mock_with` option here. + config.mock_with :rspec do |mocks| + # Prevents you from mocking or stubbing a method that does not exist on + # a real object. This is generally recommended, and will default to + # `true` in RSpec 4. + mocks.verify_partial_doubles = true + end + + # This option will default to `:apply_to_host_groups` in RSpec 4 (and will + # have no way to turn it off -- the option exists only for backwards + # compatibility in RSpec 3). It causes shared context metadata to be + # inherited by the metadata hash of host groups and examples, rather than + # triggering implicit auto-inclusion in groups with matching metadata. + config.shared_context_metadata_behavior = :apply_to_host_groups + + # The settings below are suggested to provide a good initial experience + # with RSpec, but feel free to customize to your heart's content. + # # This allows you to limit a spec run to individual examples or groups + # # you care about by tagging them with `:focus` metadata. When nothing + # # is tagged with `:focus`, all examples get run. RSpec also provides + # # aliases for `it`, `describe`, and `context` that include `:focus` + # # metadata: `fit`, `fdescribe` and `fcontext`, respectively. + # config.filter_run_when_matching :focus + # + # # Allows RSpec to persist some state between runs in order to support + # # the `--only-failures` and `--next-failure` CLI options. We recommend + # # you configure your source control system to ignore this file. + # config.example_status_persistence_file_path = "spec/examples.txt" + # + # # Limits the available syntax to the non-monkey patched syntax that is + # # recommended. For more details, see: + # # https://rspec.info/features/3-12/rspec-core/configuration/zero-monkey-patching-mode/ + # config.disable_monkey_patching! + # + # # This setting enables warnings. It's recommended, but in some cases may + # # be too noisy due to issues in dependencies. + # config.warnings = true + # + # # Many RSpec users commonly either run the entire suite or an individual + # # file, and it's useful to allow more verbose output when running an + # # individual spec file. + # if config.files_to_run.one? + # # Use the documentation formatter for detailed output, + # # unless a formatter has already been configured + # # (e.g. via a command-line flag). + # config.default_formatter = "doc" + # end + # + # # Print the 10 slowest examples and example groups at the + # # end of the spec run, to help surface which specs are running + # # particularly slow. + # config.profile_examples = 10 + # + # # Run specs in random order to surface order dependencies. If you find an + # # order dependency and want to debug it, you can fix the order by providing + # # the seed, which is printed after each run. + # # --seed 1234 + # config.order = :random + # + # # Seed global randomization in this process using the `--seed` CLI option. + # # Setting this allows you to use `--seed` to deterministically reproduce + # # test failures related to randomization by passing the same `--seed` value + # # as the one that triggered the failure. + # Kernel.srand config.seed +end diff --git a/spec/system/bin/cache_stampede_test b/spec/system/bin/cache_stampede_test new file mode 100755 index 0000000..1b1a6ed --- /dev/null +++ b/spec/system/bin/cache_stampede_test @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +cd "$(git rev-parse --show-toplevel)" || exit + +export RACK_ENV="${RACK_ENV:-production}" + +HELPERS_DIR="spec/system/bin/helpers" +source "$HELPERS_DIR/cli" +source "$HELPERS_DIR/redis" + +cleanup_port +trap 'cleanup_port' EXIT + +header "Cache Stampede Test" +info "Concurrent requests:" "1000" +info "Expected DB queries:" "1" +echo "" + +redis_flush_keys "expensive_report*" +redis_flush_keys "stampede:*" + +warn "Starting Puma..." +bundle exec puma -C config/puma.rb > /tmp/puma_test.log 2>&1 & +PUMA_PID=$! +sleep 3 +success "Puma running (PID: $PUMA_PID) — log: /tmp/puma_test.log" +echo "" + +fire_requests "http://localhost:9292/report" 1000 + +header "Results" + +QUERY_COUNT=$(redis_get "stampede:query_count") +stat "DB queries executed:" "$QUERY_COUNT" +stat "Expected:" "1" +echo "" + +if [ "$QUERY_COUNT" -eq "1" ]; then + success "Cache stampede prevented — only 1 query hit the DB" +else + error "STAMPEDE: $QUERY_COUNT queries hit the DB" +fi + +echo "" +kill $PUMA_PID 2>/dev/null diff --git a/spec/system/bin/helpers/cli b/spec/system/bin/helpers/cli new file mode 100644 index 0000000..86d88eb --- /dev/null +++ b/spec/system/bin/helpers/cli @@ -0,0 +1,41 @@ +#!/usr/bin/env bash + +HELPERS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$HELPERS_DIR/colors" + +cleanup_port() { + local port="${1:-9292}" + kill "$(lsof -ti:"$port")" 2>/dev/null || true +} + +header() { echo -e "\n${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}\n${BOLD} $1${NC}\n${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}\n"; } +success() { echo -e " ${GREEN}✓ $1${NC}"; } +error() { echo -e " ${RED}✗ $1${NC}"; } +info() { echo -e " ${CYAN}$1${NC} $2"; } +warn() { echo -e "${YELLOW}▶ $1${NC}"; } +stat() { echo -e " $1 ${BOLD}$2${NC}"; } + +fire_requests() { + local url="$1" + local count="$2" + local progress="tmp/progress" + + mkdir -p tmp + : > "$progress" + + warn "Firing $count concurrent requests..." + for _ in $(seq 1 "$count"); do + { curl -s "$url" -o /dev/null; echo . >> "$progress"; } & + done + + while true; do + local done + done=$(wc -l < "$progress" 2>/dev/null | tr -d ' ') + printf "\r Progress: %d/%d" "$done" "$count" + [ "$done" -ge "$count" ] && break + sleep 0.2 + done + echo "" + rm -f "$progress" + success "$count requests completed" +} diff --git a/spec/system/bin/helpers/colors b/spec/system/bin/helpers/colors new file mode 100644 index 0000000..78467c4 --- /dev/null +++ b/spec/system/bin/helpers/colors @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +export GREEN='\033[0;32m' +export RED='\033[0;31m' +export YELLOW='\033[1;33m' +export CYAN='\033[0;36m' +export BOLD='\033[1m' +export NC='\033[0m' diff --git a/spec/system/bin/helpers/redis b/spec/system/bin/helpers/redis new file mode 100644 index 0000000..458a25b --- /dev/null +++ b/spec/system/bin/helpers/redis @@ -0,0 +1,9 @@ +#!/usr/bin/env bash + +redis_flush_keys() { + ruby -r redis -e "r = Redis.new; r.keys(\"$1\").each { |k| r.del(k) }" +} + +redis_get() { + ruby -r redis -e "puts Redis.new.get(\"$1\") || 0" +} diff --git a/spec/system/bin/rate_limiter_test b/spec/system/bin/rate_limiter_test new file mode 100755 index 0000000..a6628f5 --- /dev/null +++ b/spec/system/bin/rate_limiter_test @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +cd "$(git rev-parse --show-toplevel)" || exit + +export RACK_ENV="${RACK_ENV:-production}" + +HELPERS_DIR="spec/system/bin/helpers" +source "$HELPERS_DIR/cli" +source "$HELPERS_DIR/redis" + +MAX_REQUESTS=10 +TOTAL_REQUESTS=3000 +CODES="tmp/rate_limit_codes" +PROGRESS="tmp/progress" + +cleanup_port +trap 'cleanup_port' EXIT + +header "Rate Limiter Load Test" +info "Max allowed:" "$MAX_REQUESTS requests/window" +info "Total requests:" "$TOTAL_REQUESTS" +info "Workers:" "4 (Puma)" +info "Threads:" "5 per worker" +echo "" + +redis_flush_keys "rate_limit:*" + +warn "Starting Puma..." +bundle exec puma -C config/puma.rb > /tmp/puma_test.log 2>&1 & +PUMA_PID=$! +sleep 3 +success "Puma running (PID: $PUMA_PID)" +echo "" + +mkdir -p tmp +: > "$CODES" +: > "$PROGRESS" + +warn "Firing $TOTAL_REQUESTS concurrent requests..." +for _ in $(seq 1 $TOTAL_REQUESTS); do + { curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:9292/limited >> "$CODES"; echo . >> "$PROGRESS"; } & +done + +while true; do + DONE=$(wc -l < "$PROGRESS" 2>/dev/null | tr -d ' ') + printf "\r Progress: %d/%d" "$DONE" "$TOTAL_REQUESTS" + [ "$DONE" -ge "$TOTAL_REQUESTS" ] && break + sleep 0.2 +done +echo "" +success "$TOTAL_REQUESTS requests completed" + +ALLOWED=$(grep -c "200" "$CODES") +DENIED=$(grep -c "429" "$CODES") +rm -f "$CODES" "$PROGRESS" + +header "Results" +stat "Allowed (200):" "$ALLOWED" +stat "Denied (429):" "$DENIED" +stat "Expected max:" "$MAX_REQUESTS" +echo "" + +if [ "$ALLOWED" -gt "$MAX_REQUESTS" ]; then + error "RACE CONDITION: $ALLOWED requests got through (limit: $MAX_REQUESTS)" +else + success "Limit held — only $ALLOWED requests allowed" +fi + +echo "" +kill $PUMA_PID 2>/dev/null diff --git a/spec/system/cache_stampede_spec.rb b/spec/system/cache_stampede_spec.rb new file mode 100644 index 0000000..b1481c9 --- /dev/null +++ b/spec/system/cache_stampede_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Cache Stampede', :system do + it 'prevents multiple DB queries under concurrent load' do + script = File.expand_path('bin/cache_stampede_test', __dir__) + output = `bash #{script} 2>&1` + puts output + expect(output).to include('✓') + expect(output).not_to include('✗') + end +end diff --git a/spec/system/rate_limiter_spec.rb b/spec/system/rate_limiter_spec.rb new file mode 100644 index 0000000..57f7eab --- /dev/null +++ b/spec/system/rate_limiter_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Rate Limiter', :system do + it 'enforces rate limit under concurrent load' do + script = File.expand_path('bin/rate_limiter_test', __dir__) + output = `bash #{script} 2>&1` + puts output + expect(output).to include('✓') + expect(output).not_to include('✗') + end +end diff --git a/test/ruby_at_scale/cache_stampede_test b/test/ruby_at_scale/cache_stampede_test deleted file mode 100755 index d160123..0000000 --- a/test/ruby_at_scale/cache_stampede_test +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env bash -cd "$(git rev-parse --show-toplevel)" - -ruby -r redis -e 'r = Redis.new; r.del("expensive_report", "expensive_report:lock", "stampede:query_count")' - -echo "Starting Puma..." -bundle exec puma -C config/puma.rb & -PUMA_PID=$! -sleep 3 - -echo "" -echo "=== Firing 1000 concurrent requests ===" -for i in $(seq 1 1000); do - curl -s http://localhost:9292/report > /dev/null & -done -wait $(jobs -p | grep -v $PUMA_PID) - -echo "" -echo "=== Verification ===" -QUERY_COUNT=$(ruby -r redis -e 'puts Redis.new.get("stampede:query_count") || 0') -echo "DB queries executed: $QUERY_COUNT" -echo "Expected: 1" - -if [ "$QUERY_COUNT" -eq "1" ]; then - echo "✓ Cache stampede prevented" -else - echo "✗ STAMPEDE: $QUERY_COUNT queries hit the DB" -fi - -kill $PUMA_PID 2>/dev/null From bf84e1bf40be0b968d9e29c3fea49c298bcc7be5 Mon Sep 17 00:00:00 2001 From: "Nikita M." Date: Wed, 20 May 2026 11:23:14 +0100 Subject: [PATCH 5/7] ci: add GitHub Actions workflow for rubocop and rspec --- .github/workflows/ci.yml | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..abc563e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,33 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + rubocop: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + - run: bundle exec rubocop + + rspec: + runs-on: ubuntu-latest + services: + redis: + image: redis:7 + ports: + - 6379:6379 + steps: + - uses: actions/checkout@v4 + - uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + - run: bundle exec rspec --tag ~system + env: + RACK_ENV: test From dd18a514fb6ba61cac4e8f53f73a49051008b180 Mon Sep 17 00:00:00 2001 From: "Nikita M." Date: Wed, 20 May 2026 11:25:05 +0100 Subject: [PATCH 6/7] rubocop fixes --- spec/ruby_at_scale/cache_stampede/solution_spec.rb | 1 - spec/ruby_at_scale/rate_limiter/solution_spec.rb | 1 - 2 files changed, 2 deletions(-) diff --git a/spec/ruby_at_scale/cache_stampede/solution_spec.rb b/spec/ruby_at_scale/cache_stampede/solution_spec.rb index abbeb87..f729323 100644 --- a/spec/ruby_at_scale/cache_stampede/solution_spec.rb +++ b/spec/ruby_at_scale/cache_stampede/solution_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true RSpec.describe RubyAtScale::CacheStampede::Solution do - let(:key) { "test:cache:#{SecureRandom.hex(4)}" } let(:redis) { Redis.new } diff --git a/spec/ruby_at_scale/rate_limiter/solution_spec.rb b/spec/ruby_at_scale/rate_limiter/solution_spec.rb index aff58f2..44af192 100644 --- a/spec/ruby_at_scale/rate_limiter/solution_spec.rb +++ b/spec/ruby_at_scale/rate_limiter/solution_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true RSpec.describe RubyAtScale::RateLimiter::Solution do - let(:redis) { Redis.new } let(:client_id) { "test:client:#{SecureRandom.hex(4)}" } From 2fbf9e1b46ad6a77ce6e55a48cf55112d7660bcb Mon Sep 17 00:00:00 2001 From: "Nikita M." Date: Wed, 20 May 2026 12:12:01 +0100 Subject: [PATCH 7/7] add non atomic version --- .../rate_limiter/sliding_window_non_atomic.rb | 43 +++++++++++++++++++ lib/ruby_at_scale/rate_limiter/solution.rb | 16 +++++-- 2 files changed, 56 insertions(+), 3 deletions(-) create mode 100644 lib/ruby_at_scale/rate_limiter/sliding_window_non_atomic.rb diff --git a/lib/ruby_at_scale/rate_limiter/sliding_window_non_atomic.rb b/lib/ruby_at_scale/rate_limiter/sliding_window_non_atomic.rb new file mode 100644 index 0000000..8cbdc0e --- /dev/null +++ b/lib/ruby_at_scale/rate_limiter/sliding_window_non_atomic.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require 'redis' + +# Non-atomic sliding window rate limiter. +# Same ZREMRANGEBYSCORE/ZCARD/ZADD approach as the Solution, +# but each command is a separate Redis call — race condition +# between ZCARD (check) and ZADD (write). +# +# Under concurrency, multiple threads read count=9, all pass, +# all ZADD — exceeding the limit. + +module RubyAtScale + module RateLimiter + class SlidingWindowNonAtomic + KEY_PREFIX = 'rate_limit' + + def initialize(redis: Redis.new, max_requests: 10, window_seconds: 60) + @redis = redis + @max_requests = max_requests + @window_seconds = window_seconds + end + + def allow?(client_id) + now = Time.now.to_f + key = "#{KEY_PREFIX}:#{client_id}" + + @redis.zremrangebyscore(key, '-inf', now - @window_seconds) + + count = @redis.zcard(key) + + if count < @max_requests + @redis.zadd(key, now, "#{now}:#{rand}") + @redis.expire(key, @window_seconds.ceil + 1) + + true + else + false + end + end + end + end +end diff --git a/lib/ruby_at_scale/rate_limiter/solution.rb b/lib/ruby_at_scale/rate_limiter/solution.rb index 0bea793..2e32242 100644 --- a/lib/ruby_at_scale/rate_limiter/solution.rb +++ b/lib/ruby_at_scale/rate_limiter/solution.rb @@ -33,13 +33,23 @@ def initialize(redis: Redis.new, max_requests: 10, window_seconds: 60) end def allow?(client_id) - now = Time.now.to_f - result = @redis.eval( + query_redis(client_id) == 1 + end + + private + + def query_redis(client_id) + now = now_timestamp + + @redis.eval( SCRIPT, keys: ["#{KEY_PREFIX}:#{client_id}"], argv: [@window_seconds, @max_requests, now, "#{now}:#{rand}"] ) - result == 1 + end + + def now_timestamp + Time.now.to_f end end end