Introduce ractorize#54
Conversation
| end | ||
|
|
||
| def freeze | ||
| @memos.default_proc = nil |
There was a problem hiding this comment.
Maybe we should also do @memos.freeze here altho its not necessary when calling Ractor.make_shareable, it seems weird to remove the default proc without freezing the hash.
There was a problem hiding this comment.
Yes it's super weird and something Ractor.make_shareable really doesn't help with because it calls freeze, checks that the object itself is frozen then moves on to freeze objects referenced by the object.
On the one hand, it makes sense, because e.g. [Object.new].freeze.first.frozen? # => false, on the other hand when designing classes it feels not helpful:
class Foo
def initialize = @foo = []
end
Ractor.shareable? Foo.new.freeze # => false
foo = Foo.new.freeze
Ractor.make_shareable foo
Ractor.shareable? foo # => trueThere's no tool to let class authors know that @foo wasn't frozen, that would help them write a comprehensive freeze implementation.
I'm not sure what it should look like though, maybe Ractor.make_shareable(obj, freeze: false) that would raise if any referenced object is not already frozen maybe?
There was a problem hiding this comment.
We should call freeze. I know it's not necessary if you're going to turn around and call make_shareable, but I usually expect calling freeze on a complex object to freeze its references.
@etiennebarrie Could you just try passing the frozen object to a Ractor and see if it raises an exception? 😅
There was a problem hiding this comment.
Sure 👍 I made this and the others freeze all their internals.
| def freeze | ||
| self.default_proc = nil | ||
| super | ||
| end |
There was a problem hiding this comment.
This breaks the contract of inheritable options.
h=ActiveSupport::InheritableOptions.new(foo: 42)
h[:foo] # => 42
h.default_proc=nil
h[:foo] # => nilIIRC I had something like:
def freeze
replace(to_h)
super
endBut that is a bit naive, trades memory for performance, we may want to go the opposite way and also make @parent frozen (and the default proc ractor shareable, since this one doesn't mutate, that should work).
There was a problem hiding this comment.
Yeah sorry 🤦 This doesn't follow the pattern of the others my bad.
I think freezing the @parent would work, but we need to pass it as self of the shareable proc I believe. And it could have unshareable values within so I think we will need to expand edouard's work to have a Ractors.make_shareable_attempt to try to freeze it 🤔
I'll think about it
There was a problem hiding this comment.
Actually if the self of the proc is shareable, and it doesn't capture variables, the proc could in theory be shareable too.
But there's a catch22 where the hash can't be made shareable because it has the proc that is not shareable because the hash is not shareable yet.
So unless I'm missing something, I don't think there's a good way to make a Hash with a default proc shareable, even if the default proc could in theory be shareable.
There was a problem hiding this comment.
Actually I don't think we can do it in the initializer. People can mutate the parent of course, so we can't freeze it then. We could do:
def freeze
Ractors.make_shareable(@parent)
self.default_proc = if @parent.kind_of?(OrderedOptions)
ActiveSupport::Ractors.shareable_proc(self: @parent) { |h, k| _get(k) }
else
ActiveSupport::Ractors.shareable_proc(self: @parent) { |h, k| self[k] }
end
super
end
That seems to work. But to your other comment above, it feels like we need way to hook into make_shareable, since the behaviour really makes no sense as part of freeze. And actually itll break in ruby 3 if we do this since shareable_proc is a no op, we will still have the inherited option as self in the block so I think we will need to add a check.
There was a problem hiding this comment.
Right, for now I think replace(to_h) is still the best solution we have.
There was a problem hiding this comment.
Sorry missed your earlier message cause we posted around same time. But yeah, making default proc shareable seems too weird, I think replace(to_h) is fine for beta support you are right.
There was a problem hiding this comment.
This thing has some APIs beyond a normal hash so I don't think its fair game to just to_h it 🤔 .
Instead I think its safer to merge the parent's entries at freezing time then remove the default proc. That way it still quacks like an inheritable options in all ways.
There was a problem hiding this comment.
Oh right, using replace keeps the class so dynamic accessors work but overridden? will need to be handled separately.
inherited = {foo: 42, bar: 21}
options = ActiveSupport::InheritableOptions.new(inherited)
options.bar = 21
options.baz = 7
assert_equal 42, options.foo
refute options.overridden?(:foo)
assert options.overridden?(:bar)
refute options.overridden?(:baz)
refute options.key?(:qux)We'll need to keep a copy of the keys before freezing.
There was a problem hiding this comment.
Yeah I was thinking about this last night........... I think I will keep track of the keys when we call []= that will simplify the overridden
There was a problem hiding this comment.
Maybe I'll pull this into its own PR since it's becoming a bit involved
fcf3ba7 to
acd62cf
Compare
| def get(path, **options) | ||
| options[:env] ||= {} | ||
| options[:env]["action_dispatch.key_generator"] ||= Generator | ||
| options[:env]["action_dispatch.key_generator"] ||= -> { Generator } |
fbb1eea to
465ce67
Compare
| "action_dispatch.logger" => Rails.logger, | ||
| "action_dispatch.backtrace_cleaner" => Rails.backtrace_cleaner, | ||
| "action_dispatch.key_generator" => key_generator, | ||
| "action_dispatch.key_generator" => ActiveSupport::Ractors.shareable_proc { Rails.application.key_generator }, |
There was a problem hiding this comment.
Is key_generator is set by the user config? Also, is this lambda is allocated on every request?
Not a show stopper, but it feels like we could wrap the key generator with a shareable proc when it's set, and cache it. That way we don't have to allocate a new proc on every request.
There was a problem hiding this comment.
It also looks like just the "caching" part of CachingKeyGenerator that may be the problem with shareability? Maybe we could swap it for the non-caching version, or make the cache have a Ractor-local or Thread-local store?
There was a problem hiding this comment.
Also, is this lambda is allocated on every request?
I don't think so. This is in the @app_env_config on the application which we freeze. It gets merged into the request env when we build the request but I don't think should allocate anything new https://github.com/rails/rails/blob/main/railties/lib/rails/engine.rb#L772
It also looks like just the "caching" part of CachingKeyGenerator that may be the problem with shareability?
Correct. I looked at just "freezing" that first by making the internal cache ractor local. But the issue is key_generators is a hash. The default is scoped to secret_key_base, but developer could have others. The issue was I wasn't sure it was a safe assumption these would all be created eagerly at boot time, so if we freeze that hash then we would break anything trying to create one when a request is in flight. That's why its the hash that is ractor local not the cache itself. But maybe it is a safe assumption and we expect people to create them in an initializer 🤔 It does make this all a lot more complicated.
Is key_generator is set by the user config?
No created by us with the default keyed to secret_key_base but there could be others (seems most common use case is old keys being rotated)
There was a problem hiding this comment.
Thinking about it today, I think could just say no new key generators at request time in Ractor mode - at least to start. I believe we are going to run into the same dilemma with AS notification subscribers 🤔
| private | ||
| def key_generator | ||
| @request.env["action_dispatch.key_generator"].call | ||
| end |
There was a problem hiding this comment.
Pulling key_generator to a method in this test seems like a good refactor regardless of Ractor safety stuff. Should we pull this change in to a different PR so we can reduce the diff here?
| end | ||
|
|
||
| def freeze | ||
| @memos.default_proc = nil |
There was a problem hiding this comment.
We should call freeze. I know it's not necessary if you're going to turn around and call make_shareable, but I usually expect calling freeze on a complex object to freeze its references.
@etiennebarrie Could you just try passing the frozen object to a Ractor and see if it raises an exception? 😅
465ce67 to
6aaebf4
Compare
…haring the application. Default procs need to be shareable in order to make their hash shareable. These procs all mutate the hash with a default option on key miss. It doesn't make sense to keep that behaviour on a frozen hash so we can just remove them. These are all configuration hashes we expect to be fully populated by the time the application is booted.
1bed0db to
a58fa93
Compare
This proc relies on referencing the instances ivar, so we can't make it shareable without making the instance shareable but we can't make the instance sharebale without making it shareable. Instead, we can merge the parent since we are now frozen and do expect to override anymore keys.
This uses a Concurrent::Map internally which is not Ractor safe. For now we will make this cache Ractor local when running in Ractor mode.
…mbol so it is safe to share.
This method prepares the application for sharing and then calls Ractor.make_shareable on it. This is an experimental feature. Currently this is only supported in eager loaded environments.
a58fa93 to
30bddaa
Compare
This PR contains the rest of the work to make a fresh Rails application ractor shareable.
We introduce the
ractorize!method which prepares the application to be frozen then callsRactor.make_shareable(self). After this the application can be shared in non-main Ractors to do interesting things like serve requests.There are a few constraints:
This PR contains a few fixes that make ractorization work:
I put those fixes on individual commits so they're easier to review. We could upstream them separately, though it might be clearer to include them all in one PR.