Skip to content
Draft
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 @@ -8,6 +8,7 @@
* [#2951](https://github.com/ruby-grape/grape/pull/2951): Re-bench only `master` in the version throughput benchmark and carry released versions' results over - [@ericproulx](https://github.com/ericproulx).
* [#2960](https://github.com/ruby-grape/grape/pull/2960): Lint every response of an API once under `lint!`, including the router's 404 and mounted Rack apps, instead of inside each endpoint's stack - [@ericproulx](https://github.com/ericproulx).
* [#2966](https://github.com/ruby-grape/grape/pull/2966): Require Ruby 3.3.1, the first 3.3 release that parses anonymous argument forwarding from inside a block - [@ericproulx](https://github.com/ericproulx).
* [#2978](https://github.com/ruby-grape/grape/pull/2978): Add Ractor mode (Ruby 4.0 and later): `Grape.ractor!` and `MyAPI.finalize!` freeze a compiled API, and the process-wide state a request reads, so that it can be served from non-main Ractors - [@ericproulx](https://github.com/ericproulx).
* Your contribution here.

#### Fixes
Expand Down
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4410,6 +4410,45 @@ Grape integrates with following third-party tools:
* **[ElasticAPM](https://www.elastic.co/products/apm)** - [elastic-apm](https://github.com/elastic/apm-agent-ruby) gem, [documentation](https://www.elastic.co/guide/en/apm/agent/ruby/3.x/getting-started-rack.html#getting-started-grape)
* **[Datadog APM](https://docs.datadoghq.com/tracing/)** - [ddtrace](https://github.com/datadog/dd-trace-rb) gem, [documentation](https://docs.datadoghq.com/tracing/setup_overview/setup/ruby/#grape)

## Ractor Mode

> Experimental, and only as solid as Ruby's own Ractors, which are experimental themselves.
> Needs Ruby 4.0 or later: earlier Rubies cannot make a `Method` object shareable, and an API is
> full of them -- every endpoint holds its route block as one. `Grape.ractor!` raises there.

A Grape API is defined, compiled and frozen in the main Ractor, and can then be served from as many Ractors as you like -- in parallel, with no GVL between them. Turn the mode on before the API classes load, and finalize the API once it is fully defined:

```ruby
# config.ru
require 'grape'
Grape.ractor!

require_relative 'api'

run MyAPI.finalize!
```

`Grape.ractor!` has to come first because it changes how a route block becomes a method: Ruby refuses to call a method defined from an unshareable `Proc` from another Ractor, so in this mode every block is isolated as it is read. A block that reads an outer variable holding something unshareable cannot be isolated, and says so as the class loads rather than on the first request:

```ruby
limit = +'10' # a mutable String

Class.new(Grape::API) do
get('/items') { limit } # Ractor::IsolationError, while the class loads
end
```

`finalize!` compiles the API, freezes the whole object graph behind it, and settles the process-wide state a request reads: Grape's own configuration, the lookup tables Rack and Builder fill as they load, and a snapshot of the `grape` translations. Nothing can be defined on the API or configured on Grape afterwards -- both answer a write with an error from then on. It returns the API class, so `run MyAPI.finalize!` reads as one step.

Serving from Ractors is the server's job; Grape's part is being ready for it. A server that runs the app in the main Ractor serves a finalized API just as well.

What the mode gives up:

* **Instrumentation.** `ActiveSupport::Notifications` keeps its notifier where a non-main Ractor cannot read it, so the `*.grape` hook points are skipped.
* **Per-request locale.** I18n keeps its configuration in class variables, which a non-main Ractor may not read at all. Messages come from a frozen snapshot of the locale that was default when `finalize!` ran.
* **File uploads.** Ruby's `Tempfile` is a `Delegator`, and a delegated method cannot be called from a non-main Ractor, so a multipart request carrying a file cannot be parsed there.
* **Reloading.** A finalized API cannot be changed, so reloading in development is out.

## Contributing to Grape

Grape is work of hundreds of contributors. You're encouraged to submit pull requests, propose features and discuss issues.
Expand Down
26 changes: 26 additions & 0 deletions lib/grape.rb
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@
module Grape
extend Dry::Configurable

@ractor = false

setting :param_builder, default: :hash_with_indifferent_access
setting :lint, default: false
setting :warn_on_helper_overrides, default: false
Expand Down Expand Up @@ -102,6 +104,30 @@ module Grape
# a method deprecated through this deprecator announced a removal version
# that had already shipped, and every custom `behavior` lambda -- how a Rails
# app consumes deprecations -- was handed the same wrong number.
# Ractor mode. A Grape API is defined and compiled in the main Ractor -- a
# non-main one may not write a class instance variable at all, which is where
# the DSL keeps everything -- and is then frozen whole and served from as
# many Ractors as the application wants (see Grape::API::Instance.finalize!).
#
# This has to be on before the API classes load, because it decides how a
# route block is turned into a method: Ruby refuses to call a method defined
# from a Proc that is not shareable from another Ractor, so in this mode the
# blocks are isolated as they are read. Turn it on in an initializer, above
# the requires that define the API.
#
# Needs a Ruby that can make a Method object shareable, which is Ruby 4.0
# and later; on an earlier one this raises rather than letting the failure
# surface later, out of the middle of a frozen object graph.
def self.ractor!
raise Grape::Exceptions::RactorModeUnsupported unless Grape::Util::Shareable.supported?

@ractor = true
end

def self.ractor?
@ractor
end

def self.deprecator
@deprecator ||= ActiveSupport::Deprecation.new("#{Gem::Version.new(VERSION).segments.first + 1}.0", 'Grape')
end
Expand Down
2 changes: 1 addition & 1 deletion lib/grape/api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ class API
# protected methods too, so it has to be named here for {.override_all_methods!}
# to leave it alone. +base+ is read on the API class itself whenever a mount
# is refreshed, so recording it would refresh every mount below it again.
NON_OVERRIDABLE = %i[base base= base_instance? call change! configuration compile! inherit_settings recognize_path reset! routes top_level_setting].freeze
NON_OVERRIDABLE = %i[base base= base_instance? call change! configuration compile! finalize! inherit_settings recognize_path reset! routes top_level_setting].freeze

# DSL methods that answer a setting when called with nothing to set -- no
# argument, keyword or block -- and change none. Such a call is a read, and
Expand Down
17 changes: 17 additions & 0 deletions lib/grape/api/instance.rb
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,23 @@ def recognize_path(path)
compile!.router.recognize_path(path)
end

# Compile this API and make the compiled app shareable, so that it can
# be served from non-main Ractors -- the whole object graph behind it
# is frozen, and so is the process-wide state a request reads (see
# Grape::Util::Shareable).
#
# Nothing may be defined on the API, or configured on Grape, after
# this: both answer a write with a FrozenError from here on. Returns
# the API class it was called on, so a rackup file can
# +run MyAPI.finalize!+.
def finalize!
raise Grape::Exceptions::RactorModeNotEnabled unless Grape.ractor?

Grape::Util::Shareable.freeze_globals!
::Ractor.make_shareable(compile!)
base || self
end

# Wipe the compiled API so we can recompile after changes were made.
def change!
@instance = nil
Expand Down
10 changes: 10 additions & 0 deletions lib/grape/endpoint.rb
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ class << self
def block_to_unbound_method(block)
return unless block

# In Ractor mode the block has to be isolated before it becomes a
# method: Ruby refuses to call a method defined from an unshareable
# Proc from another Ractor. A block that reads an outer variable
# holding something unshareable cannot be isolated, and says so here,
# as the API class loads, rather than on the first request.
::Ractor.make_shareable(block) if Grape.ractor?
define_method :temp_unbound_method, block
method = instance_method(:temp_unbound_method)
remove_method :temp_unbound_method
Expand Down Expand Up @@ -262,24 +268,28 @@ def options?
# directly (no added allocations); the block is forwarded anonymously so
# nothing is allocated unless a subscriber is present.
def instrument_run(&)
return yield if Grape.ractor?
return yield unless ActiveSupport::Notifications.notifier.listening?('endpoint_run.grape')

ActiveSupport::Notifications.instrument('endpoint_run.grape', endpoint: self, env:, &)
end

def instrument_render(&)
return yield if Grape.ractor?
return yield unless ActiveSupport::Notifications.notifier.listening?('endpoint_render.grape')

ActiveSupport::Notifications.instrument('endpoint_render.grape', endpoint: self, &)
end

def instrument_run_validators(validators, request, &)
return yield if Grape.ractor?
return yield unless ActiveSupport::Notifications.notifier.listening?('endpoint_run_validators.grape')

ActiveSupport::Notifications.instrument('endpoint_run_validators.grape', endpoint: self, validators:, request:, &)
end

def instrument_run_filters(filters, type, &)
return yield if Grape.ractor?
return yield unless ActiveSupport::Notifications.notifier.listening?('endpoint_run_filters.grape')

ActiveSupport::Notifications.instrument('endpoint_run_filters.grape', endpoint: self, filters:, type:, &)
Expand Down
11 changes: 11 additions & 0 deletions lib/grape/exceptions/ractor_mode_not_enabled.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# frozen_string_literal: true

module Grape
module Exceptions
class RactorModeNotEnabled < Base
def initialize
super(message: compose_message(:ractor_mode_not_enabled))
end
end
end
end
11 changes: 11 additions & 0 deletions lib/grape/exceptions/ractor_mode_unsupported.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# frozen_string_literal: true

module Grape
module Exceptions
class RactorModeUnsupported < Base
def initialize
super(message: compose_message(:ractor_mode_unsupported, ruby_version: RUBY_VERSION))
end
end
end
end
2 changes: 2 additions & 0 deletions lib/grape/locale/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ en:
summary: 'when version using header, you must specify :vendor option'
mutual_exclusion: 'are mutually exclusive'
oneof: 'does not match any of the allowed schemas'
ractor_mode_unsupported: 'Ractor mode needs a Ruby that can make a Method shareable, which is 4.0 and later; this is %{ruby_version}'
ractor_mode_not_enabled: 'finalize! needs Ractor mode, which has to be turned on with Grape.ractor! before the API classes load'
presence: 'is missing'
regexp: 'is invalid'
same_as: 'is not the same as %{parameter}'
Expand Down
9 changes: 8 additions & 1 deletion lib/grape/middleware/formatter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ def initialize(content_types: nil, default_format: :txt, format: nil, formatters

ALL_MEDIA_TYPES = '*/*'

# Rack leaves its own copy of this table unfrozen, in a constant a
# non-main Ractor may not read (see Grape::Util::Shareable), and every
# response reads it. A copy of Rack's, frozen, so that Grape neither
# reaches for Rack's nor has to freeze a constant it does not own.
STATUS_WITH_NO_ENTITY_BODY = Rack::Utils::STATUS_WITH_NO_ENTITY_BODY.dup.freeze

# The query param that names the format. See #format_from_query.
FORMAT_PARAM = 'format'

Expand Down Expand Up @@ -98,7 +104,7 @@ def after

status, headers, bodies = @app_response

return [status, headers, []] if Rack::Utils::STATUS_WITH_NO_ENTITY_BODY.include?(status)
return [status, headers, []] if STATUS_WITH_NO_ENTITY_BODY.include?(status)

build_formatted_response(status, headers, bodies)
end
Expand Down Expand Up @@ -134,6 +140,7 @@ def build_formatted_response(status, headers, bodies)
# notification machinery are skipped and the block runs directly (no added
# allocations); the block is forwarded anonymously.
def instrument_format_response(formatter, &)
return yield if Grape.ractor?
return yield unless ActiveSupport::Notifications.notifier.listening?('format_response.grape')

ActiveSupport::Notifications.instrument('format_response.grape', formatter:, env:, &)
Expand Down
14 changes: 14 additions & 0 deletions lib/grape/request.rb
Original file line number Diff line number Diff line change
Expand Up @@ -143,11 +143,25 @@ class Request < Rack::Request
alias rack_params params
alias rack_cookies cookies

class << self
# The query parser every Grape request parses its query string with, or
# nil to leave Rack to its own default. Ractor mode sets it (see
# Grape::Util::Shareable), because Rack keeps its default in a class
# instance variable, which a non-main Ractor may not read.
attr_accessor :query_parser
end

def initialize(env, build_params_with: nil)
super(env)
@build_params_with = build_params_with
end

# Rack answers +@query_parser+ first and falls back to its own default, so
# this only steps in front of that fallback.
def query_parser
Grape::Request.query_parser || super
end

def params
@params ||= make_params
end
Expand Down
130 changes: 130 additions & 0 deletions lib/grape/util/shareable.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
# frozen_string_literal: true

module Grape
module Util
# Everything a process has to settle once before a compiled API can be
# served from non-main Ractors, which may read no unshareable value a
# class, a module or a constant holds.
#
# Each step freezes state that is written while an application boots and
# only read once it serves, so freezing it costs a booted process nothing
# -- and says so loudly if something writes to it afterwards.
module Shareable
FALLBACK_LOCALE = :en
private_constant :FALLBACK_LOCALE

# Whole modules rather than named constants and readers, so that a table
# added upstream is covered too, and because which ones a given
# application reaches is not knowable by reading: +Rack::Utils+ is here
# for +SYMBOL_TO_STATUS_CODE+, which nothing touches until an endpoint
# answers +status :created+, and for +ESCAPE_HTML+, which only an HTML
# error response reads, and only on Rack 2.2.
#
# Builder backs +to_xml+, and is loaded before they are frozen because
# the XML formatter would otherwise first reach for it from a Ractor,
# and find its tables unfrozen. +Rack::Multipart::Parser+ is here
# although an upload cannot work in a Ractor at all, so that it fails
# where the reason is (Ruby's Tempfile is a Delegator) rather than on a
# constant this could have frozen.
DEPENDENCY_GLOBALS = %w[
Rack::Utils Rack::QueryParser Rack::Request::Helpers Rack::Headers Rack::Multipart::Parser
ActiveSupport::XmlMini Builder::XChar JSON MultiJson MultiJSON MultiXml
].freeze
private_constant :DEPENDENCY_GLOBALS

module_function

# Ruby 3.3 and 3.4 refuse to make a Method or an UnboundMethod shareable,
# and an API is full of them: every endpoint holds its route block as one
# (Endpoint#source), and dry-types builds its coercers out of them. So
# nothing can be finalized there, whatever the API looks like.
def supported?
::Ractor.make_shareable(Object.instance_method(:itself))
true
rescue ::Ractor::Error
false
end

def freeze_globals!
freeze_config!
freeze_dependency_globals!
snapshot_translations!
end

# dry-configurable memoizes a setting the first time it is read, so a
# config frozen before that raises FrozenError on the first read instead
# of answering it. Read every setting first and the memo is complete,
# which is what makes the frozen config readable at all -- from any
# Ractor, from then on.
def freeze_config!
return if Grape.config.frozen?

Grape.config._settings.each { |setting| Grape.config[setting.name] }
::Ractor.make_shareable(Grape.config)
end

# The tables and options a request reads out of Grape's dependencies,
# which a non-main Ractor may not touch while they are mutable. Both
# places they are kept: constants, such as Rack's media type lists, and
# class instance variables, such as Rack's default query parser or the
# json gem's +dump_default_options+, which every JSON response reads
# before json 3. Each is filled as its library loads and only read
# afterwards, so freezing it costs a booted process nothing -- upstream
# candidates, all.
def freeze_dependency_globals!
begin
require 'builder'
rescue LoadError
nil
end

# Grape's own copy of Rack's default parser, frozen: Rack keeps its in a
# class instance variable, and this leaves that one alone.
Grape::Request.query_parser = ::Ractor.make_shareable(Rack::Utils.default_query_parser.dup)

DEPENDENCY_GLOBALS.each do |name|
next unless Object.const_defined?(name)

mod = Object.const_get(name)
mod.constants.each { |const| share { mod.const_get(const) } }
mod.instance_variables.each { |ivar| share { mod.instance_variable_get(ivar) } }
end
end

# What cannot be read or frozen is what this never promised to cover: the
# request path reaches for tables and options, not for live objects, and
# a constant can also be one an optional file would have to define
# (JSON::GenericObject, for one, which +constants+ lists and +const_get+
# then refuses).
def share
::Ractor.make_shareable(yield)
rescue ::Ractor::Error, NameError
nil
end

# I18n is out of reach entirely: its configuration lives in class
# variables, which a non-main Ractor may not even read. Take a frozen
# copy of the loaded translations instead, which {Translation} answers
# from in Ractor mode -- for the default locale, since choosing one per
# request is exactly what is out of reach.
#
# Only the +grape+ subtree is taken, which is everything Grape ever looks
# a message up under. The rest of a locale is not ours to freeze, and
# cannot be anyway -- ActiveSupport's own +en+ holds lambdas.
def snapshot_translations!
::I18n.backend.__send__(:init_translations) unless ::I18n.backend.initialized?

loaded = ::I18n.backend.translations
locale = loaded.key?(::I18n.default_locale) ? ::I18n.default_locale : FALLBACK_LOCALE
subtree = loaded.dig(locale, :grape) || loaded.dig(FALLBACK_LOCALE, :grape)
@translations = ::Ractor.make_shareable({ grape: subtree.deep_dup })
end

# The frozen translations for the locale that was default when the
# process was finalized, or nil before that.
def translations
@translations
end
end
end
end
Loading
Loading