Skip to content

Make dry-initializer usable from non-main Ractors after finalize#111

Open
flash-gordon wants to merge 3 commits into
mainfrom
ractor-compatibility
Open

Make dry-initializer usable from non-main Ractors after finalize#111
flash-gordon wants to merge 3 commits into
mainfrom
ractor-compatibility

Conversation

@flash-gordon
Copy link
Copy Markdown
Member

@flash-gordon flash-gordon commented May 12, 2026

Summary

Adds Ractor compatibility to dry-initializer. After Klass.finalize, a class is usable from any Ractor — .new, subclassing, attribute reads, default Procs that reference other params, all work cross-Ractor.

The public seal entry point is now Dry::Initializer#finalize (no bang). The previous finalize! is gone, and the old internal regen step has been renamed to a private Config#compile.

After finalize:

  • __dry_initializer_config__ and klass.dry_initializer are rewritten from bmethods into class_eval'd defs reading a shareable DRY_INITIALIZER_CONFIG constant.
  • The Config (with its definitions and default Procs) is deeply frozen via Ractor.make_shareable. Further param/option calls raise FrozenError.

Pre-finalize, class-level Config storage no longer relies on writing @dry_initializer ivars (forbidden in non-main Ractors): both DSL#extended and Dry::Initializer#inherited install a define_singleton_method on the class, which finalize later swaps for the Ractor-safe def.

Other changes pulled along to keep the path clean:

  • Config#children derives live from Class#subclasses instead of an owned Set — same immediate-only semantics, no mutation.
  • Mixin::Local is removed entirely; Config#mixin uses Module#set_temporary_name to keep the per-class mixin printing as Dry::Initializer::Mixin::Local[<Class>] in ancestors/inspect.
  • Dispatchers.@pipeline is eagerly initialized at load and Ractor.make_shareabled so the default pipeline is readable from any Ractor. Custom dispatchers still work but opt out of Ractor compatibility unless registered with shareable Procs.
  • spec/ractor_spec.rb and spec/inspect_spec.rb cover the new surface end-to-end.

@flash-gordon flash-gordon force-pushed the ractor-compatibility branch 3 times, most recently from 81af65d to 390ce64 Compare May 12, 2026 18:25
@flash-gordon
Copy link
Copy Markdown
Member Author

Note: we may want to rename finalize to finalize!

Copy link
Copy Markdown
Member

@timriley timriley left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not a Dry Initializer expect, but upon my first reading, these changes all look sensible. Thank you for putting this together, @flash-gordon!

Aside from my inline comments, a couple of more general thoughts below.

From your PR description:

The public seal entry point is now Dry::Initializer#finalize (no bang). The previous finalize! is gone, and the old internal regen step has been renamed to a private Config#compile.

I think our method name here was always just .finalize (no bang)? I can't find any trace of finalize! in the main branch or git history, at least.

Note: we may want to rename finalize to finalize!

Why do you suggest this? Seems like finalize was the API before (as noted above), and I'm not sure if we really have sufficient justification for introducing the bang. I think the method name on its own already conveys its intent, and we don't have "non-mutative" or "somehow less dangerous" equivalent method that needs us to introduce the bang as a differentiator.

(I know that we have Dry::System::Container.finalize! and tbh, I feel like that'd be better without the bang, it doesn't feel necessary there either).

One thing to note about this PR is that the class-level .finalize becomes an important piece of public API, whereas before it was an internal concern only.

At minimum, this means:

  • We'll need to update docs over in https://github.com/hanakai-rb/site for this new version
  • We should make it clear that finalizing a class freezes the ancestor chain's initializer configs (Ractor.make_shareable walking through @parent, as I mentioned via inline). The good news is that adding params to a subclass of a finalized class still works (which is what we actually document at https://hanakai.org/learn/dry/dry-initializer/v3.2/inheritance). But what doesn't work is adding params to a parent after any descendant has been finalized. Feels like this is worth noting both on the method API docs as well as our website docs.

I also wonder whether there's a more specific name we could use for this method so that we avoid potential name collisions. Dry Initializer gets used across all kinds of classes, and "finalize" is generic enough that users might want to use it for their own API. A standalone call to "finalize" inside a class definition is also not clear enough that it actually relates to the initializer params.

What if we called it "finalize_initializer"? It's wordier, but it makes it the connection to the initializer clearer, both in terms of the Ruby method as well as the gem that it came from. What do you think? I'm open to other ideas too, please share your suggestions!

Thanks again for doing this, I love that we're being responsive to user needs even in these more mature gems of ours ❤️

end
RUBY

# Replace the bmethod `klass.dry_initializer` from `DSL#extended`
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# Replace the bmethod `klass.dry_initializer` from `DSL#extended`
# Replace the method `klass.dry_initializer` from `DSL#extended`

typo?

UPDATE: upon seeing "bmethod" repeated further down in this diff, and then googling harder, I realise "bmethod" is shorthand for "a method defined by define_method". Maybe it's fine to leave in, then. But I do think it's possibly confusing to future readers — it doesn't feel like a common Ruby term. What do you think?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An LLM picked it up from MRI's internals. It can be useful for further LLM-aided work on the sources so I'd change it to the block-based version (aka bmethod in MRI) ...

Comment on lines +167 to +169
# Rebuild the generated initializer on the mixin and recurse into
# children. Called on every DSL change. Private — it makes no
# Ractor-readiness guarantees; only {#finalize} does.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can I just say that I really appreciate the level of commenting you've added here. This will be really helpful for our future selves and any other contributors ❤️

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Contrary to before, it now takes time to quench Claude's desire to elaborate on every single thing. When the context grows, an LLM tries to dump all the intermediary steps we took, even though they are mostly irrelevant to the final result

PrepareDefault, PrepareOptional,
UnwrapType, CheckType, BuildNestedType, WrapType
]
defined?(Ractor) ? Ractor.make_shareable(list) : list.freeze
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wow, this is going to be kind of ungainly if we're going to have to repeat this across large parts of our ecosystem.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree! But for this particular thing, I have a justification. It's a questionable design from the start: modifying the internal pipeline from outside. It should have been provided via a simple plugin API. This design led to such clumsy branching. If we were to release 4.0 of the gem it'd be a good candidate for rework.

Comment thread spec/custom_dispatchers_spec.rb Outdated
Dry::Initializer::Dispatchers << dispatcher
end

# The Dispatchers pipeline is process-global, so reset it after each
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# The Dispatchers pipeline is process-global, so reset it after each
# The Dispatcher's pipeline is process-global, so reset it after each

end

# Finalizes config
# Seal the config. After finalize the class is usable from non-main
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I notice here we're introducing "seal" as a special term for the first time.

I wonder if we could instead just continue to use "finalize" here (and then explain the consequences for Ractor usage, which you already do) rather than introducing another concept? Please correct me if I'm wrong, but "seal" doesn't seem super widely used (I did see one mention here: https://bugs.ruby-lang.org/issues/21665) and so perhaps it'd be better to avoid introducing a different word.

Comment thread spec/ractor_spec.rb
RSpec.describe Dry::Initializer, "Ractor compatibility" do
before do
skip "Ractor not available" unless defined?(Ractor)
skip "Ractor tests gated to Ruby 4.0+" if RUBY_VERSION < "4"
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Didn't we get Ractors before 4.0? Are we gating the tests to 4+ because it's the first "good" version of Ractor? Or do you have other reasons?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

because it's the first "good" version

Mostly this, but I will take a closer look later. I won't be merging this until a PR to dry-validation is sent at least

Comment thread lib/dry/initializer/config.rb Outdated
@mixin ||= Module.new.tap do |mod|
initializer = self
mod.extend(Mixin::Local)
mod.set_temporary_name("Dry::Initializer::Mixin::Local[#{extended_class&.name}]")
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

extended_class.name may be nil for anonymous classes. Maybe we could have it return the plain inspect in that case, so we get e.g. Dry::Initializer::Mixin::Local[#<Class:0x...>] instead of Dry::Initializer::Mixin::Local[] (which feels a bit confusing).

I know this is a super edge case, but I thought I'd mention it anyway! We may as well make this solid while we're introducing this overhaul.

@flash-gordon
Copy link
Copy Markdown
Member Author

flash-gordon commented May 15, 2026

Why do you suggest this?

@timriley I wrote it because the next thing was dry-configurable, which already has finalize! as its public API, so having two versions for basically the same thing would be confusing. But finalize_initializer sounds a lot better and makes perfect sense to me

Introduce `Dry::Initializer#finalize` (and `Config#finalize`) as the
single public seal entry point — the previous `finalize!` is gone, and
the old internal regen step is now a private `Config#compile`.

After `finalize`, the class is callable from any Ractor:

* `__dry_initializer_config__` and `klass.dry_initializer` are
  rewritten from bmethods into `class_eval`'d `def`s that read a
  shareable `DRY_INITIALIZER_CONFIG` constant.
* The Config (with its definitions and default Procs) is deeply
  frozen via `Ractor.make_shareable`. Further `param`/`option` calls
  raise `FrozenError`.

Pre-finalize, class-level Config storage no longer relies on writing
`@dry_initializer` ivars (forbidden in non-main Ractors): both
`DSL#extended` and `Dry::Initializer#inherited` install a
`define_singleton_method` on the class, which `finalize` later swaps
for the Ractor-safe `def`.

Other changes pulled along to keep the path clean:

* `Config#children` derives live from `Class#subclasses` instead of an
  owned `Set` — same immediate-only semantics, no mutation.
* `Mixin::Local` is removed entirely; `Config#mixin` uses
  `Module#set_temporary_name` to keep the per-class mixin printing as
  `Dry::Initializer::Mixin::Local[<Class>]` in `ancestors`/`inspect`.
* `Dispatchers.@pipeline` is eagerly initialized at load time and
  `Ractor.make_shareable`d so the default pipeline is readable from
  any Ractor. Custom dispatchers still work but opt out of Ractor
  compatibility unless registered with shareable Procs.
* `spec/ractor_spec.rb` and `spec/inspect_spec.rb` cover the new
  surface end-to-end.
@flash-gordon flash-gordon force-pushed the ractor-compatibility branch from 390ce64 to f305ac1 Compare May 15, 2026 13:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants