Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
* [#2692](https://github.com/ruby-grape/grape/pull/2692): Replace per-request `Proc` allocation in `Router#transaction` with a `halt?` helper - [@ericproulx](https://github.com/ericproulx).
* [#2694](https://github.com/ruby-grape/grape/pull/2694): Split `Versioner::Base#available_media_types` into an `attr_reader` plus `build_available_media_types` - [@ericproulx](https://github.com/ericproulx).
* [#2695](https://github.com/ruby-grape/grape/pull/2695): Lift trailing `if/else` into guard clauses - [@ericproulx](https://github.com/ericproulx).
* [#2696](https://github.com/ruby-grape/grape/pull/2696): Reduce per-request allocations on the request hot path; migrate middleware options to `attr_reader` and freeze `@options` post-init - [@ericproulx](https://github.com/ericproulx).
* Your contribution here.

#### Fixes
Expand Down
26 changes: 26 additions & 0 deletions UPGRADING.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,32 @@ Upgrading Grape

### Upgrading to >= 3.3

#### `Grape::Middleware::Base#options` is now frozen

`@options` is frozen at the end of `Grape::Middleware::Base#initialize` (after `merge_default_options`). The hash is initialized once and treated as immutable for the lifetime of the middleware. Custom middleware that mutates `options[...]` at runtime will now raise `FrozenError`.

If your custom middleware was patching its own options on the fly:

```ruby
# Before
class MyMiddleware < Grape::Middleware::Base
def before
options[:flag] = compute_flag
# ...
end
end

# After — store mutable runtime state on a dedicated ivar
class MyMiddleware < Grape::Middleware::Base
def before
@flag = compute_flag
# ...
end
end
```

Reading `options[...]` is unchanged.

#### `Grape::Request#grape_routing_args` has been removed

`grape_routing_args` was previously public to support third-party `params_builder` extensions, which have since been removed. With no remaining callers, the method has been removed. If you were calling it externally, read `env[Grape::Env::GRAPE_ROUTING_ARGS]` directly.
Expand Down
48 changes: 48 additions & 0 deletions benchmark/version_throughput/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# version_throughput

Cross-version throughput benchmark for Grape. Measures `BenchAPI.call(env)` requests-per-second for a tiny JSON endpoint, against the same `app.rb` definition exec'd under several Grape releases plus `master`. Used to track regressions and validate refactor wins.

## Files

| File | Role |
|---|---|
| `app.rb` | The API under test. Kept deliberately small and version-agnostic — only DSL surface stable across Grape 3.x, so the same file can run against `3.0.0 … master` without edits. |
| `bench.rb` | Single-version benchmark. Loads `app.rb`, sanity-checks the response, runs `Benchmark.ips` (2s warmup + 5s measure), prints one `RESULT,<ips>,<μs>,<stddev>,<yjit>` line for the orchestrator. |
| `run.rb` | Orchestrator. For each version: writes a `Gemfile`, runs `bundle install` under `tmp/bench-versions/<version>/`, exec's `bench.rb` once without YJIT and once with `--yjit` (if available), parses results, writes `RESULTS.md`. |
| `RESULTS.md` | Generated report — overwritten on every run. |

## Usage

```sh
# default: 3.0.0, 3.0.1, 3.1.0, 3.1.1, 3.2.0, 3.2.1, master
ruby benchmark/version_throughput/run.rb

# subset
GRAPE_VERSIONS="3.2.1,master" ruby benchmark/version_throughput/run.rb

# different Ruby (e.g. one built with YJIT)
RBENV_VERSION=4.0.3 ruby benchmark/version_throughput/run.rb
```

`master` is benched against the working tree (`gemspec path: <repo root>`), so unstaged changes are picked up. All other versions resolve to released gems on rubygems.org.

## Output

Each version produces a row in `RESULTS.md`:

| Version | No-YJIT (i/s) | μs/req | YJIT (i/s) | μs/req | YJIT speedup |
|---|---:|---:|---:|---:|---:|
| … | … | … | … | … | … |

YJIT columns are only emitted if the running Ruby was built with YJIT support (`run.rb` probes via `ruby --yjit -e 'exit(defined?(RubyVM::YJIT) ? 0 : 1)'`).

## Interpreting results

- **Noise floor is ~5-8%.** A single 5s window on macOS can easily move a few percent under thermal throttling or background load. Rerun before drawing conclusions on small deltas.
- **Run on a quiet machine.** Close other apps, plug in the laptop, don't touch the keyboard during the run. Each version takes ~14s of wall-clock measurement plus bundle install on first use.
- **`master` vs released gems is not apples-to-apples for code paths that changed.** If a refactor moved code between files, both numbers still measure the same `app.rb` request — that's the point — but interpret deltas as "end-to-end request cost" rather than per-method.
- **YJIT speedup is `(yjit_ips - no_yjit_ips) / no_yjit_ips`.** Both passes share the same Ruby binary; only the `--yjit` flag differs.

## Adding a version

Edit `DEFAULT_VERSIONS` in `run.rb`. The orchestrator handles `bundle install` and gemfile generation; nothing else needs to change as long as the new version exposes the DSL `app.rb` uses (`prefix`, `format`, `version 'v1', using: :path`, `get`).
16 changes: 16 additions & 0 deletions benchmark/version_throughput/app.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# frozen_string_literal: true

# Version-agnostic Grape API used by version_throughput benchmark.
# Kept tiny and using only DSL surface that's been stable across Grape 3.x —
# so the same script can be exec'd against 3.0.0 ... master without changes.
require 'grape'

class BenchAPI < Grape::API
prefix :api
format :json
version 'v1', using: :path

get '/hello' do
{ hello: 'world' }
end
end
27 changes: 27 additions & 0 deletions benchmark/version_throughput/bench.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# frozen_string_literal: true

# Single-version throughput bench. Invoked once per Grape version by
# `run.rb`. Loads `app.rb` (which is what gets compared across versions),
# warms up, then prints a single `RESULT,<ips>,<us_per_iter>,<stddev_pct>`
# line the orchestrator parses.
$LOAD_PATH.unshift(File.expand_path('.', __dir__))
require 'benchmark/ips'
require 'app'

env_template = Rack::MockRequest.env_for('/api/v1/hello', method: Rack::GET).freeze

# Sanity check: 200 OK, body contains expected payload
status, _, body = BenchAPI.call(env_template.dup)
abort("sanity check failed: status=#{status}") unless status == 200
collected = +''
body.each { |c| collected << c }
abort("sanity check failed: body=#{collected.inspect}") unless collected.include?('world')

report = Benchmark.ips do |ips|
ips.config(time: 5, warmup: 2, quiet: true)
ips.report('throughput') { BenchAPI.call(env_template.dup) }
end

entry = report.entries.first
yjit = (defined?(RubyVM::YJIT) && RubyVM::YJIT.enabled?) ? 'on' : 'off'
puts format('RESULT,%.2f,%.4f,%.2f,%s', entry.ips, 1_000_000.0 / entry.ips, entry.error_percentage, yjit)
183 changes: 183 additions & 0 deletions benchmark/version_throughput/run.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
# frozen_string_literal: true

# Orchestrator: runs benchmark/version_throughput/bench.rb against each
# Grape version listed below in a clean subprocess (one bundle per version
# under tmp/), parses the RESULT line, and writes a Markdown table to
# benchmark/version_throughput/RESULTS.md.
#
# Each version is benched twice: once without YJIT, once with `--yjit`
# (skipped if the running Ruby wasn't built with YJIT). Results show both
# columns plus the YJIT speedup.
#
# Usage:
# ruby benchmark/version_throughput/run.rb
#
# To bench against a specific subset:
# GRAPE_VERSIONS="3.0.0,3.2.1,master" ruby benchmark/version_throughput/run.rb
#
# To run a YJIT-enabled Ruby that isn't the project default:
# RBENV_VERSION=4.0.3 ruby benchmark/version_throughput/run.rb

require 'fileutils'
require 'open3'
require 'rbconfig'

ROOT = File.expand_path('../..', __dir__)
HERE = __dir__
TMP = File.join(ROOT, 'tmp', 'bench-versions')

DEFAULT_VERSIONS = %w[3.0.0 3.0.1 3.1.0 3.1.1 3.2.0 3.2.1 master].freeze
versions = (ENV['GRAPE_VERSIONS']&.split(',')&.map(&:strip) || DEFAULT_VERSIONS).freeze

def gemfile_for(version)
if version == 'master'
<<~G
source 'https://rubygems.org'
gemspec path: '#{ROOT}'
gem 'benchmark-ips'
G
else
<<~G
source 'https://rubygems.org'
gem 'grape', '#{version}'
gem 'benchmark-ips'
gem 'rack'
G
end
end

def prepare(version)
dir = File.join(TMP, version)
FileUtils.mkdir_p(dir)
File.write(File.join(dir, 'Gemfile'), gemfile_for(version))
dir
end

def run_bundle_install(dir)
Open3.capture2e({ 'BUNDLE_GEMFILE' => File.join(dir, 'Gemfile') }, 'bundle', 'install', '--quiet', chdir: dir)
end

def run_bench(dir, yjit:)
args = ['bundle', 'exec', 'ruby']
args << '--yjit' if yjit
args << File.join(HERE, 'bench.rb')
Open3.capture2e({ 'BUNDLE_GEMFILE' => File.join(dir, 'Gemfile') }, *args)
end

def parse_result(stdout)
line = stdout.lines.reverse.find { |l| l.start_with?('RESULT,') }
return nil unless line

_, ips, us, stddev, yjit = line.strip.split(',')
{ ips: ips.to_f, us: us.to_f, stddev: stddev.to_f, yjit: yjit }
end

def yjit_available?
out, status = Open3.capture2e('ruby', '--yjit', '-e', 'exit(defined?(RubyVM::YJIT) ? 0 : 1)')
status.success? && !out.include?('without YJIT support')
end

with_yjit = yjit_available?
puts "YJIT available in current Ruby: #{with_yjit}"

results = {}
versions.each do |version|
print "[#{version}] preparing... "
dir = prepare(version)
install_out, install_status = run_bundle_install(dir)
unless install_status.success?
puts "FAILED (bundle install)\n#{install_out}"
results[version] = { error: 'bundle install failed' }
next
end

results[version] = {}

# Pass 1: no YJIT
print 'no-yjit... '
bench_out, bench_status = run_bench(dir, yjit: false)
if bench_status.success? && (parsed = parse_result(bench_out))
results[version][:no_yjit] = parsed
printf('%.0f i/s', parsed[:ips])
else
print 'FAILED'
results[version][:no_yjit] = { error: bench_status.success? ? 'no RESULT line' : 'bench failed', stdout: bench_out }
end

# Pass 2: --yjit (skip if not available)
if with_yjit
print ' yjit... '
bench_out, bench_status = run_bench(dir, yjit: true)
if bench_status.success? && (parsed = parse_result(bench_out))
results[version][:yjit] = parsed
printf('%.0f i/s', parsed[:ips])
else
print 'FAILED'
results[version][:yjit] = { error: bench_status.success? ? 'no RESULT line' : 'bench failed', stdout: bench_out }
end
end
puts
end

# Write Markdown report
ruby_desc = `ruby -e 'puts RUBY_DESCRIPTION'`.strip
host_desc = `uname -mrs 2>/dev/null`.strip
report_path = File.join(HERE, 'RESULTS.md')

format_ips = ->(n) { n.round.to_s.reverse.scan(/\d{1,3}/).join(',').reverse }

File.open(report_path, 'w') do |f|
f.puts "# Grape throughput by version\n\n"
f.puts "Generated: #{Time.now.strftime('%Y-%m-%d %H:%M:%S %Z')} "
f.puts "Ruby: #{ruby_desc} "
f.puts "Host: #{host_desc} "
f.puts "YJIT available: #{with_yjit}\n\n"
f.puts 'Single-threaded `Benchmark.ips`, 2s warmup + 5s measure, ' \
'`BenchAPI.call(env)` against `/api/v1/hello` returning a small JSON object. ' \
"Reproduce with `ruby benchmark/version_throughput/run.rb`.\n\n"

if with_yjit
f.puts '| Version | No-YJIT (i/s) | μs/req | YJIT (i/s) | μs/req | YJIT speedup |'
f.puts '|---|---:|---:|---:|---:|---:|'
else
f.puts '| Version | Throughput (i/s) | μs/req | ± stddev |'
f.puts '|---|---:|---:|---:|'
end

versions.each do |version|
r = results[version]
if r.is_a?(Hash) && r[:error]
cols = with_yjit ? 5 : 3
f.puts "| #{version} | error: #{r[:error]} #{'|' * cols}"
next
end

no_yjit = r[:no_yjit]
yjit = r[:yjit]

if with_yjit
no_yjit_cell = no_yjit&.dig(:ips) ? format_ips.call(no_yjit[:ips]) : 'err'
no_yjit_us = no_yjit&.dig(:us) ? format('%.2f', no_yjit[:us]) : ''
yjit_cell = yjit&.dig(:ips) ? format_ips.call(yjit[:ips]) : 'err'
yjit_us = yjit&.dig(:us) ? format('%.2f', yjit[:us]) : ''
speedup = (no_yjit&.dig(:ips) && yjit&.dig(:ips)) ?
format('%+.1f%%', (yjit[:ips] - no_yjit[:ips]) / no_yjit[:ips] * 100.0) : '—'
f.puts "| #{version} | #{no_yjit_cell} | #{no_yjit_us} | #{yjit_cell} | #{yjit_us} | #{speedup} |"
else
cell = no_yjit&.dig(:ips) ? format_ips.call(no_yjit[:ips]) : 'err'
us = no_yjit&.dig(:us) ? format('%.2f', no_yjit[:us]) : ''
stddev = no_yjit&.dig(:stddev) ? format('±%.2f%%', no_yjit[:stddev]) : ''
f.puts "| #{version} | #{cell} | #{us} | #{stddev} |"
end
end

f.puts "\n## Notes"
f.puts '- All versions exercised through the same `BenchAPI` definition (kept stable in `app.rb`).'
f.puts '- Results are noisy at this scale (±5-8%); rerun if a number looks off.'
if with_yjit
f.puts '- `YJIT speedup` is `(yjit_ips - no_yjit_ips) / no_yjit_ips`.'
f.puts '- YJIT pass uses `ruby --yjit`; both passes share the same Ruby binary.'
end
end

puts "\nWritten: #{report_path}"
10 changes: 5 additions & 5 deletions lib/grape/api/instance.rb
Original file line number Diff line number Diff line change
Expand Up @@ -175,18 +175,18 @@ def collect_route_config_per_pattern(all_routes)
end
end

ROOT_PREFIX_VERSIONING_KEY = %i[version version_options root_prefix].freeze
private_constant :ROOT_PREFIX_VERSIONING_KEY
ROOT_PREFIX_VERSIONING_KEYS = %i[version version_options root_prefix].freeze
private_constant :ROOT_PREFIX_VERSIONING_KEYS

# Allows definition of endpoints that ignore the versioning configuration
# used by the rest of your API.
def without_root_prefix_and_versioning
inheritable_setting = self.class.inheritable_setting
deleted_values = inheritable_setting.namespace_inheritable.delete(*ROOT_PREFIX_VERSIONING_KEY)
deleted_values = inheritable_setting.namespace_inheritable.delete(*ROOT_PREFIX_VERSIONING_KEYS)
yield
ensure
ROOT_PREFIX_VERSIONING_KEY.each_with_index do |key, index|
inheritable_setting.namespace_inheritable[key] = deleted_values[index]
ROOT_PREFIX_VERSIONING_KEYS.zip(deleted_values) do |key, value|
inheritable_setting.namespace_inheritable[key] = value
end
end
end
Expand Down
16 changes: 9 additions & 7 deletions lib/grape/endpoint.rb
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ def inspect

def run
ActiveSupport::Notifications.instrument('endpoint_run.grape', endpoint: self, env:) do
@request = Grape::Request.new(env, build_params_with: inheritable_setting.namespace_inheritable[:build_params_with])
@request = Grape::Request.new(env, build_params_with: @build_params_with)
begin
run_filters befores, :before
@before_filter_passed = true
Expand All @@ -150,7 +150,6 @@ def run
header['Allow'] = env[Grape::Env::GRAPE_ALLOWED_METHODS].join(', ')
raise Grape::Exceptions::MethodNotAllowed.new(header) unless options?

header 'Allow', header['Allow']
response_object = ''
status 204
else
Expand Down Expand Up @@ -215,11 +214,7 @@ def run_filters(filters, type = :other)
end
end

%i[befores before_validations after_validations afters finallies].each do |method|
define_method method do
inheritable_setting.namespace_stackable[method]
end
end
attr_reader :befores, :before_validations, :after_validations, :afters, :finallies

def options?
options[:options_route_enabled] &&
Expand All @@ -233,6 +228,13 @@ def options?
def compile!
@app = options[:app] || build_stack
@helpers = build_helpers
stackable = inheritable_setting.namespace_stackable
@befores = stackable[:befores]
@before_validations = stackable[:before_validations]
@after_validations = stackable[:after_validations]
@afters = stackable[:afters]
@finallies = stackable[:finallies]
@build_params_with = inheritable_setting.namespace_inheritable[:build_params_with]
end

def to_routes
Expand Down
Loading
Loading