Ractor Safe Rails

Ractor Safe Rails

We would like to add experimental support for serving Rails requests from non-main Ractors.

Ractors are Ruby’s actor-based concurrency primitive for parallel execution without shared mutable state. Today, Rails applications and framework internals rely on mutable global state in ways that prevent a Rails application from being made shareable or called safely from a non-main Ractor.

The goal of this work is to establish a minimal, opt-in foundation for Ractor-compatible request handling in Rails.

Conceptually, we would like this shape to become possible:

Ractor.make_shareable(Rails.application)

Ractor.new(env) do |env|
  Rails.application.call(env)
end

Strategy

The big picture end-goal is to run requests inside of a non-main Ractor. There are multiple ways to achieve that goal, but we want to take an “outside in” approach. That means we want to serve requests within non-main Ractors and fallback to the main Ractor for incompatible code, rather than going “inside out” by making all code Ractor-safe first and then serving requests fully from non-main Ractors.

We are choosing this approach because we believe it allows us to make progress more quickly. Even if only 10% of the request is run outside of the main Ractor, that is 10% more than we have today.

Additionally, this approach doesn’t require application authors to change their applications. For example, nobody is required to wrap code in Ractor.new { … } blocks.

The outside-in approach is still compatible with other approaches (that could be worked on in parallel). We just believe it offers a practical path to realizing benefits in production more quickly. Over time, we can move more of the request path off the main Ractor and reduce the amount of work that must be delegated back to it.

Initial scope

Our initial goal for experimental support is for a newly generated Rails application to serve a set of scaffolded routes from a non-main Ractor.

This gives us a small enough target to make progress while exercising a meaningful portion of the framework including:

  • Boot process & configuration

  • Database access

  • View rendering

  • Routing & middleware

The initial experiment will not support all Rails features. Explicitly out of scope:

  • Development mode (eg lazy loading)

  • Action Cable

  • Active Job

  • Non-Rails dependencies like Solid Cache

Once a baseline of support is established we expect to expand support to the above and other areas, with the support of the community.

Discovery process

The process of discovering all the changes needed to make the framework Ractor safe is labour intensive. The process involves booting the application, trying to make it shareable and fixing the first error encountered. This must be repeated until the application is shareable, and then again until a request can be served.

To help speed up this process we have completed a number of AI driven prototypes. Here is one example AI prototype that can successfully serve scaffolded routes from a non-Main Ractor. The code and solutions in this prototype are not what we intend to propose upstream. The prototypes provide a map of the problem space that needs to be covered in order to make the framework Ractor-safe.

You can see the scaffolded application that we are using to test Ractor support here.

Pragmatic tradeoffs for experimental support

The goal of this initial phase is experimental support only, with no guarantee of performance or production readiness. To that end, we expect to make pragmatic tradeoffs to meet our goal of serving requests from non-main Ractors.

Some work will initially be dispatched back to the main Ractor when it depends on behavior that is difficult or impossible to perform from a non-main Ractor, such as code loading or lazily evaluated global state. These fallbacks will be explicit and treated as areas for future improvement. Explicitly stated, our goal is that 0% of the request should be dispatched to the main Ractor, but initially it will be greater than 0%.

Similarly, some internal framework caches may initially become Ractor-local rather than globally shared. Common concurrent data structures like Concurrent::Map are not Ractor-safe, so Ractor-local caches may be the most practical first step. Over time, we can evaluate which caches should be shareable, which should remain Ractor-local, and which should be accessed through explicit Ractor-safe APIs.

Next steps

We welcome your questions and feedback on this proposal.

We have also begun opening PRs with some initial patterns for Ractor-safety that we would like to establish. You can find those tagged with the Ractor support label.

1 Like

So, I’ve already said this in other places and in private to some of your colleagues, but I really don’t agree with the benefits of what you call “outside-in”.

Even if only 10% of the request is run outside of the main Ractor, that is 10% more than we have today.

Well, the thing with that approach, is that unless your whole application is Ractor compatible, which unless it’s a trivial demo or benchmarking app is really not realistic, you get zero benefits.

To get any benefit, you need to start the request in a Ractor, so using one of the handful of experimental Ractor based servers, which is impossible if your controllers or model reach to any Ractor incompatible gems (which are most gems out there).

In other words, for the average Rails user who’s running Puma and Sidekiq, this project is causing lots of small disruptions, for no gain. Because all the extra freezing will necessarily break some people’s app, and requires way more allocations on boot. I’m not saying it’s a bad thing, Ractor or not, global mutable state is best avoided, but I think it needs to be said.

This “outside-in” is fundamentally black and white, either you manage to make your application Ractor-safe (totally unrealistic for the foreseeable future), or either you get no upside at all.

It is also fundamentally black and white in the sense that if your team priority changes tomorrow and the project is canceled or paused. Even imaginary people with a Ractor-safe Rails application won’t be able to run their app in Ractors because the project is incomplete. There’s only one possible milestone which is near 100% completion.

That’s why I’ve been advocating against that approach for several years now (including publicly).

Instead I do believe that what you call the “inside-out” approach is inherently superior.

An example of the inside-out approach would be to re-architecture some internal components such as Active Record, to be Ractor based. Meaning the queries, their parsing and the allocation of Active Record models would happen in a background Ractor through an async first API.

This for instance would allow to get rid of Active Record’s async_queries thread pool, and would benefit Puma and Sidekiq users as we’d gradually shift more work out of the main Ractor, allowing them to progressively increase the number of Puma/Sidekiq threads without experiencing more contention.

Contrary to the “outside-in” approach, projects like this one offer many smaller milestones, such that if the project is paused, abandoned or partially complete by next release, there is still tangible benefits for ALL the Rails community.

I really don’t understand this part. If your applicative code (including all the gems you call into) runs inside a Ractor, it needs to be Ractor safe. That will requires loads of changes on the app-side and in the gem ecosystem. No-one will achieve this any time soon with any non-trivial app.

I just don’t think this is the case. If we have an escape hatch (such as deferring code to the main Ractor), then we can have portions of application code running not on the main Ractor and are able to see benefit.

Can you be more specific? The work we’re doing will need to be done someday regardless of “outside in” vs “inside out” approach.

I don’t think this is the case, and is exactly why we need the ability to defer work to the main Ractor. Anything that’s “too hard at the moment” gets deferred and we can slowly move things off of the main Ractor while still being able to run a Ractor based web server.

I think this is a great idea and an example of a component that we know we need to make Ractor safe as well as the right way to do it. But we can’t do this with components like Action Pack or Railties for example. There’s not a good way of understanding what needs to be done without actually doing it.

I think delegating things to the main Ractor provides the same benefit. Smaller milestones because not everything needs to be made Ractor safe all at once, and yet some of the internals are still able to run off of the main Ractor.

I don’t think “outside in” and “inside out” are incompatible strategies. Both can be done at the same time. I do think an “outside in” strategy will get us to running Rails in a Ractor based web server sooner as well as uncover any “unknown unknowns” in the system. And having the ability to delegate some work to the main Ractor will allow us to do the work piecemeal.

Ractor dispatch is indeed an escape hatch, but it’s not an universal one, it has the same drawback as deferring to a secondary Ractor, depending on what data you need to pass around, it may be very expensive. It’s not something you want to sprinkle around.

But beyond that, for many dependencies it can be very complex to dispatch to the main Ractor, think of all the gems that extend Rails, all these will need to be Ractor compatible, etc.

Can you be more specific? The work we’re doing will need to be done someday regardless of “outside in” vs “inside out” approach.

The work you’re currently doing is only beneficial if running a Ractor based server. There is a few options but none is battle tested in production.

If you run Puma or Sidekiq, there is 0 gain, only downsides because various state get refactored and frozen, as well as callback marked as shareable and that will likely cause small breakages there and there (which is fine, just pointing it out).

The value proposition just isn’t there, and likely won’t be there for a while.

Whereas if you make some sub components use Ractors under the hood, you provide benefits to users right there, you get feedback, bug reports, etc.

I don’t think this is the case, and is exactly why we need the ability to defer work to the main Ractor.

Dispatching to the main Ractor is a hacky way to make your app “compatible”, but even with that hack, it’s far from trivial. You’re not going to make an app like Discourse work that way.

But we can’t do this with components like Action Pack or Railties for example.

Active Record is kind of a “leaf” component of Rails, it doesn’t have much dependencies on other parts.

I think delegating things to the main Ractor provides the same benefit.

Again, I think you are overestimating the effectiveness of Ractor dispatch.

Anyways, I summarized my position here because Ganon told me about it, so I figured it was the occasion to braindump on the topic in a single place.

But I’ve been saying the same thing to you and others on the team for almost two years now, and clearly it’s not landing, so I should just give up. I’m just very annoyed by the complexity added to Rails for no-one benefit’s.