Module: RactorRailsShim
- Defined in:
- lib/ractor_rails_shim.rb,
lib/ractor_rails_shim/version.rb,
lib/ractor_rails_shim/callbacks.rb,
lib/ractor_rails_shim/roles/check.rb,
lib/ractor_rails_shim/patches/core.rb,
lib/ractor_rails_shim/patches/i18n.rb,
lib/ractor_rails_shim/patches/mail.rb,
lib/ractor_rails_shim/patches/rack.rb,
lib/ractor_rails_shim/patches/devise.rb,
lib/ractor_rails_shim/patches/marcel.rb,
lib/ractor_rails_shim/patches/warden.rb,
lib/ractor_rails_shim/roles/freezers.rb,
lib/ractor_rails_shim/patches/openssl.rb,
lib/ractor_rails_shim/roles/installer.rb,
lib/ractor_rails_shim/roles/lifecycle.rb,
lib/ractor_rails_shim/patches/kaminari.rb,
lib/ractor_rails_shim/patches/rubygems.rb,
lib/ractor_rails_shim/roles/worker_app.rb,
lib/ractor_rails_shim/foundation/funnel.rb,
lib/ractor_rails_shim/patches/callables.rb,
lib/ractor_rails_shim/patches/propshaft.rb,
lib/ractor_rails_shim/callbacks/registry.rb,
lib/ractor_rails_shim/foundation/storage.rb,
lib/ractor_rails_shim/roles/fallback_ies.rb,
lib/ractor_rails_shim/foundation/registry.rb,
lib/ractor_rails_shim/foundation/run_mode.rb,
lib/ractor_rails_shim/patches/action_view.rb,
lib/ractor_rails_shim/patches/orm_adapter.rb,
lib/ractor_rails_shim/patches/url_helpers.rb,
lib/ractor_rails_shim/patches/activerecord.rb,
lib/ractor_rails_shim/patches/rails_module.rb,
lib/ractor_rails_shim/patches/route_helpers.rb,
lib/ractor_rails_shim/roles/ar_model_walker.rb,
lib/ractor_rails_shim/roles/pre_spawn_steps.rb,
lib/ractor_rails_shim/patches/active_storage.rb,
lib/ractor_rails_shim/patches/active_support.rb,
lib/ractor_rails_shim/patches/make_shareable.rb,
lib/ractor_rails_shim/patches/mattr_accessor.rb,
lib/ractor_rails_shim/roles/callback_capture.rb,
lib/ractor_rails_shim/roles/fallback_builder.rb,
lib/ractor_rails_shim/roles/install_strategy.rb,
lib/ractor_rails_shim/foundation/ies_accessor.rb,
lib/ractor_rails_shim/patches/action_dispatch.rb,
lib/ractor_rails_shim/patches/class_attribute.rb,
lib/ractor_rails_shim/roles/app_shareabilizer.rb,
lib/ractor_rails_shim/foundation/role_defaults.rb,
lib/ractor_rails_shim/foundation/version_check.rb,
lib/ractor_rails_shim/roles/worker_app_factory.rb,
lib/ractor_rails_shim/foundation/const_reassign.rb,
lib/ractor_rails_shim/foundation/version_policy.rb,
lib/ractor_rails_shim/patches/action_controller.rb,
lib/ractor_rails_shim/patches/execution_wrapper.rb,
lib/ractor_rails_shim/patches/zeitwerk_registry.rb,
lib/ractor_rails_shim/patches/polymorphic_routes.rb,
lib/ractor_rails_shim/foundation/storage_strategy.rb,
lib/ractor_rails_shim/patches/active_record_store.rb,
lib/ractor_rails_shim/roles/logger_io_neutralizer.rb,
lib/ractor_rails_shim/callbacks/symbolic_transport.rb,
lib/ractor_rails_shim/roles/constant_shareabilizer.rb,
lib/ractor_rails_shim/roles/shareability_traversal.rb,
lib/ractor_rails_shim/patches/active_model_attribute.rb,
lib/ractor_rails_shim/patches/hash_compute_if_absent.rb,
lib/ractor_rails_shim/patches/activerecord_reflection.rb,
lib/ractor_rails_shim/patches/active_record_model_schema.rb,
lib/ractor_rails_shim/callbacks/dependent_association_transport.rb
Overview
Callbacks::DependentAssociationTransport — replays a model's dependent:
association cascades on the :destroy kind. This is the reference transport
for a callback that is a LAMBDA (the dependent: option registers an
unshareable before_destroy lambda) but whose effect reduces to a
shareable, declarative spec: (class_name => [type:, macro:]).
On :destroy, for each captured dependent association the transport calls
record.association(name).handle_dependency — the exact method the original
lambda invoked — so :destroy / :delete / :nullify / :restrict_* all
dispatch correctly (that dispatch lives in AR's handle_dependency).
Source shape (frozen into SHAREABLE_DEPENDENT_ASSOCIATIONS):
{ class_name(String) => [ {name: Symbol, type: Symbol, macro: Symbol}, … ] }
Keyed by class name (not object_id) because dependent: replay looks up the
record's own class, not its ancestors (a class only cascades the associations
it itself declared).
Defined Under Namespace
Modules: ARModelWalker, ActionDispatchStrategy, ActiveModelAttributePatch, ActiveModelAttributeRegistrationPatch, ActiveRecordAttributesPatch, ActiveRecordModelSchemaPatch, ActiveRecordStoreInstancePatch, ActiveRecordStorePatch, ActiveStorageAttachedPatch, AppShareabilizer, CallbackCapture, Callbacks, ConstReassign, ConstantShareabilizer, FallbackBuilder, Freezers, Funnel, IESAccessor, InstallStrategy, Installer, Lifecycle, LoggerIONeutralizer, MarcelPatch, Patches, PreSpawnSteps, ReflectionAbstractPatch, ReflectionAssociationPatch, ReflectionMacroPatch, ReflectionMemoPatch, ReflectionThroughPatch, Registry, RoleDefaults, RunMode, ShareabilityTraversal, Storage, StorageStrategy, Version, VersionPolicy, WorkerAppFactory Classes: ArWorkerInitWrapper, Callable, CallableConst, Check, DeviseMappingSnapshot, NoOpCond, NoOpLock, NoOpLogDev, NoOpProc, WorkerApp
Constant Summary collapse
- VERSION =
"0.4.0"- KEYS =
The keys under which each global is stored in IsolatedExecutionState. Namespaced to avoid collisions with Rails' own uses of IES.
{ application: :ractor_rails_shim_application, app_class: :ractor_rails_shim_app_class, cache: :ractor_rails_shim_cache, logger: :ractor_rails_shim_logger, env: :ractor_rails_shim_env, backtrace_cleaner: :ractor_rails_shim_backtrace_cleaner }.freeze
- CLASS_ATTRIBUTES =
The nine shared registries. Issue #35 (Round 4): storage OWNS the mutable registries (Array/Hash) — the facade constants below are the SAME object as Registry's instance variables, so appends are visible through both paths. The frozen (shareable) registries are swapped via
_reassign_shareable_constwhich updates BOTH the facade constant (for the string-eval'd code that readsRactorRailsShim::SHAREABLE_FALLBACK) and Registry (so role objects that readRegistry.shareable_fallbacksee the new value). New code should preferRactorRailsShim::Registry. Registry.class_attributes
- MATTR_DEFAULTS =
Registry.mattr_defaults
- CLASS_ATTR_VALUES =
Registry.class_attr_values
- SHAREABLE_MATTR_DEFAULTS =
Registry.shareable_mattr_defaults
- SHAREABLE_CONSTANTS =
Registry.shareable_constants
- SHAREABLE_CLASS_IVARS =
Registry.shareable_class_ivars
- ABSTRACT_REGISTRY =
Registry.abstract_registry
- VIEW_CONTEXT_REGISTRY =
Registry.view_context_registry
- SHAREABLE_FALLBACK =
Registry.shareable_fallback
- SHAREABLE_PENDING_ATTR_MODS =
Per-model
pending_attribute_modifications(custom attribute macros: user defaults, type decorators) captured in the main Ractor during prepare_for_ractors! and read by workers (whose class-ivar space is separate from main's under kino). Keyed by model object_id so a worker can find its model's pending modifications. Reassigned (frozen + shareable) at prepare time; models with unshareable modifications (e.g. a Proc-backed decorator) are simply omitted, degrading to [] in workers. {}.freeze
- SHAREABLE_GEN_ATTR_METHODS =
Shareable map of { model_object_id => Module } capturing each AR model's in main). Worker Ractors read this to reuse the pre-built attribute methods instead of creating an empty Module (which would leave name=, id=, etc. undefined and cause DelegationError on ActiveStorage::Attachment).
{}.freeze
- PATCH_VERSIONS =
Registry of patch names → tested Rails version segments. Owned by VersionPolicy; the constant here is an alias so the historical RactorRailsShim::PATCH_VERSIONS reference keeps working.
RactorRailsShim::VersionPolicy::PATCH_VERSIONS
- UnsupportedVersionError =
Alias for backward compatibility — errors are catchable as either RactorRailsShim::UnsupportedVersionError or RactorRailsShim::VersionPolicy::UnsupportedVersionError.
RactorRailsShim::VersionPolicy::UnsupportedVersionError
- SUPPORTED_RUBY =
RactorRailsShim::Version::SUPPORTED_RUBY
- SUPPORTED_RAILS =
"8.1"- NON_DISPATCHED_FRAMEWORK_PATCHES =
install*_patch methods called from OTHER install paths, not from the dispatcher. The constant + the dispatcher live on Installer (extracted Issue #13, Step 13.6); kept as a facade delegation so the existing framework_patch_dispatch_spec (which reads the dispatcher's source location) and version_spec keep passing. See Installer for the contract.
RactorRailsShim::Installer::NON_DISPATCHED_FRAMEWORK_PATCHES
- FILES_LOC =
Source-location constant used by make_app_shareable!'s proc-replacement graph traversal (moved here from make_shareable.rb so the Rack concern's pieces live together).
"/rack/files.rb".freeze
- DEVISE_SCOPE_LOC =
Source-location constant for the Devise scope constraint Proc (moved from make_shareable.rb so the Devise-related callable lives with the Devise patch).
"/devise/rails/routes.rb".freeze
- FallbackIES =
Storage::ThreadLocal
- SHAREABLE_COMPILED_MODULE =
Shareable, mutable module that holds compiled template methods (e.g.
_app_views_...). ActionView attaches compiled template methods tocompiled_method_container; the default returns a per-class container, which isolates the shared application layout's compiled method to whichever controller first rendered it (so other controllers raise NoMethodError on the layout). Routing every view_context_class to this ONE shared module makes compiled methods available to all controllers/workers. It is a plain Module (shareable without freezing, so workers can still define methods on it). Module.new
- AR_PRIMARY_KEYS_SHAREABLE =
Shareable snapshot of each AR model class's primary_key, captured at prepare time. Workers read this instead of the raw @primary_key class ivar (which is initialized to PRIMARY_KEY_NOT_SET, a BasicObject that can't be made shareable). Populated by _share_model_classes! in the main ractor.
Ractor.make_shareable({})
- SHAREABLE_DEPENDENT_ASSOCIATIONS =
Shareable table of
dependent:associations, captured at prepare time so worker Ractors can replay them. The Railsdependent: <type>option registers a LAMBDAbefore_destroyfilter (->(o) { o.association(reflection.name).handle_dependency }). A lambda is an unshareable Proc, so the model's__callbackschain can't be made Ractor-shareable and workers get an EMPTY destroy chain — meaning dependent children are never deleted/cascade in workers (FK-violation 500). We instead capture (model_name => [type:, macro:]) and, in a worker's empty:destroychain, callrecord.association(name).handle_dependencydirectly (the exact method the original lambda invoked). Entries are Hashes of Symbols/booleans -> natively shareable. nil- PgBindBlock =
Shareable callable that replaces Arel::Visitors::PostgreSQL::BIND_BLOCK (a Proc
proc { |i| "$#{i}" }). Callable cross-Ractor. Ractor.make_shareable(Object.new.tap do |o| def o.call(i); "$#{i}"; end def o.to_proc; method(:call).to_proc; end end)
- SqlBindBlock =
Shareable callable that replaces Arel::Visitors::ToSql::BIND_BLOCK (a Proc
proc { "?" }). Callable cross-Ractor. Ractor.make_shareable(Object.new.tap do |o| def o.call(_i = nil); "?"; end def o.to_proc; method(:call).to_proc; end end)
- AR_CONFIGURATIONS_SNAPSHOT =
Shareable snapshot of ActiveRecord::Base.configurations at prepare_for_ractors! time. Workers read this to establish their own connection pools with the same db config. Made shareable (frozen).
nil- AR_CONFIGURATIONS_SHAREABLE =
Shareable (deep-frozen) copy of ActiveRecord::Base.configurations (the DatabaseConfigurations object) captured at prepare time. Workers read this instead of the raw
@@configurationsclass variable, which a non-main Ractor cannot access. nil- AR_DB_CONFIG_HANDLERS_SHAREABLE =
Shareable (deep-frozen) copy of DatabaseConfigurations.db_config_handlers (an Array of shareable handler Procs) captured at prepare time. Workers read this instead of the per-Ractor class instance variable.
nil- AR_QUERY_TRANSFORMERS_SHAREABLE =
Shareable (deep-frozen) copy of ActiveRecord.query_transformers (an Array of transformer classes/objects) captured at prepare time. Workers read this instead of the per-Ractor class instance variable.
nil- ACTIVE_STORAGE_PREFIX =
Shareable constants holding ActiveStorage's table-name prefix/suffix. Seeded with the real (string) values in main at prepare_for_ractors! time (see
_seed_active_storage_prefix!); workers read these instead of the un-shareablemattr_accessorreaders. Reassigned (not mutated) in main. "".freeze
- ACTIVE_STORAGE_SUFFIX =
"".freeze
- SSL_LOC =
Source-location constants used by make_app_shareable!'s proc-replacement graph traversal (moved here from make_shareable.rb so each concern's pieces live together).
"/active_dispatch/middleware/ssl.rb".freeze
- COOKIE_LOC =
"/session/cookie_store.rb".freeze
- MAPPER_LOC =
"/action_dispatch/routing/mapper.rb".freeze
- EMPTY_CALLBACKS_HASH =
Frozen shared sentinel for the
__callbacksclass_attribute's missing-slot default. Rails callers index the result (__callbacks[:process_action]), so a missing slot must return an empty Hash rather than nil. A single frozen constant avoids the per-read{}allocation the original reader had. Defined on the module (not the singleton class) so it's readable asRactorRailsShim::EMPTY_CALLBACKS_HASHfrom the eval'd method bodies. Made Ractor-shareable so worker Ractors can read it too. Ractor.make_shareable({}.freeze)
- SHAREABLE_ALLOW_FORGERY =
Captured at prepare_for_ractors! time: the main Ractor's resolved ActionController forgery-protection flag. Replayed in worker Ractors (see _install_action_controller_forgery_patch) because
allow_forgery_protectiondelegates toconfig.allow_forgery_protection, and a worker's sharedconfigresolves to an EMPTY OrderedOptions -> the flag is lost, so no CSRF token is ever emitted in workers (forms render without an authenticity token, and POST/CSRF validation can't be exercised). A boolean is shareable. false
Class Attribute Summary collapse
-
._abstract_registry ⇒ Object
Accessor for the abstract-controller registry (written by abstract! in main, read by abstract? in workers).
-
._view_context_fallback ⇒ Object
Returns the value of attribute _view_context_fallback.
-
._view_context_registry ⇒ Object
Returns the value of attribute _view_context_registry.
- .storage_strategy ⇒ Object
Class Method Summary collapse
-
._apply_active_storage_blob_patch ⇒ Object
ActiveStorage::Blobis loaded after the attached macros, so it is patched in a later TracePoint event. - ._apply_active_storage_macro_patch ⇒ Object
- ._apply_activerecord_autosave_patch ⇒ Object
- ._apply_activerecord_reflection_patch ⇒ Object
- ._apply_activerecord_scope_patch ⇒ Object
- ._capture_ar_configurations! ⇒ Object
-
._capture_dependent_associations! ⇒ Object
Capture every AR model's
dependent:associations into the shareable SHAREABLE_DEPENDENT_ASSOCIATIONS table so worker Ractors can replay them (see StorageStrategy::Ractor#replay_callbacks! / the :destroy branch of the nil-safe run_callbacks patch). -
._check_version_support ⇒ Object
Verify the runtime matches the versions the shim was developed against.
-
._class_attr_methods(method_name, namespaced_name, missing_default) ⇒ Object
Build the reader/writer pair for one method name.
-
._devise_mapping_replacement(proc_obj, _parent) ⇒ Object
Build a shareable replacement for a Devise scope constraint.
- ._devise_mapping_snapshot(mapping) ⇒ Object
-
._find_files_server(mw) ⇒ Object
Find the Rack::Files (asset) server in the middleware chain, used by make_app_shareable! when replacing the Rack::Head#@app lambda (whose binding receiver is the Rack::Files instance).
-
._fix_blob_metadata_type! ⇒ Object
Force-rebuild
ActiveStorage::Blob's_default_attributesand@attribute_typesin MAIN so the:metadataattribute uses the correctType::Serializedbefore the graph is frozen. -
._freeze_class_ivars!(owner) ⇒ Object
Make every unshareable class ivar on
ownershareable (deep-freeze) and write it back. -
._freeze_journey_visitors! ⇒ Object
Journey's routing visitors are stored as instance singletons in class constants (e.g.
ActionDispatch::Journey::Visitors::Each::INSTANCE). -
._freeze_mime_negotiation! ⇒ Object
ActionDispatch::Http::MimeNegotiation holds module-level constants (e.g. RESCUABLE_MIME_FORMAT_ERRORS, an Array of exception classes) that are referenced from the request path (params_readable? ->
rescue * RESCUABLE_MIME_FORMAT_ERRORS). -
._freeze_secure_random_alphabets! ⇒ Object
Freeze
SecureRandom::BASE36_ALPHABET/BASE58_ALPHABETif they exist and are not yet shareable. -
._install_abstract_controller_patch ⇒ Object
Patch AbstractController::Base.controller_path to not write/read the action_methods, clear_action_methods!, abstract!, abstract?, and _prefixes to route through IES or use the shareable fallback.
-
._install_action_controller_controller_name_patch ⇒ Object
Patch ActionController::Metal.controller_name (a class method).
-
._install_action_controller_forgery_patch ⇒ Object
Replay ActionController's forgery-protection flag in worker Ractors.
-
._install_action_dispatch_http_url_patch ⇒ Object
ActionDispatch::Http::URL reads the
tld_lengthclass variable DIRECTLY (@@tld_length) innormalize_hostand in the default-parameter ofdomain/subdomains/subdomain. -
._install_action_dispatch_mounted_helpers_patch ⇒ Object
Patch ActionDispatch::Routing::RouteSet::MountedHelpers#main_app (and its _main_app worker).
-
._install_action_dispatch_routing_patch ⇒ Object
Patch ActionDispatch::Routing::RouteSet URL generation.
-
._install_action_view_field_type_patch ⇒ Object
Patch ActionView::Helpers::Tags::TextField.field_type (and the subclasses EmailField/PasswordField/... that inherit it).
-
._install_action_view_partial_path_patch ⇒ Object
Patch ActionView::AbstractRenderer::ObjectRendering#partial_path.
-
._install_action_view_resolver_patch ⇒ Object
Patch ActionView::FileSystemResolver#_find_all.
-
._install_action_view_safe_join_patch ⇒ Object
Patch ActionView::Helpers::OutputSafetyHelper#safe_join.
-
._install_action_view_sanitize_patch ⇒ Object
ActionView::Helpers::SanitizeHelper::ClassMethods memoizes its sanitizer instances as class ivars on the (shared, frozen) module (@safe_list_sanitizer / @full_sanitizer / @link_sanitizer).
-
._install_actionmailer_mailer_name_patch ⇒ Object
--- ActionMailer::Base.mailer_name (raw class ivar on the mailer subclass) ---
mailer_name(aliased tocontroller_path) computes the mailer's view path prefix from its class name, but reads the lazy@mailer_nameclass ivar — unreadable from a worker Ractor ("can not get unshareable values from instance variables of classes/modules from non-main Ractors"). -
._install_active_model_attribute_method_patterns_patch ⇒ Object
ActiveModel::AttributeMethods::ClassMethods#attribute_method_patterns_cache stores a mutable Concurrent::Map in a CLASS instance variable (@attribute_method_patterns_cache).
-
._install_active_model_attribute_patch ⇒ Object
See patches/active_model_attribute.rb.
-
._install_active_model_conversion_patch ⇒ Object
Patch ActiveModel::Conversion::ClassMethods#_to_partial_path to route its lazy class-ivar cache (
@_to_partial_path ||= ...) through IsolatedExecutionState. -
._install_active_model_naming_patch ⇒ Object
(an ActiveModel::Name holding unfrozen, unshareable Strings) on the model class.
-
._install_active_record_core_patch ⇒ Object
Patch ActiveRecord::Core::ClassMethods#arel_table / #predicate_builder / #type_caster.
-
._install_active_record_inheritance_patch ⇒ Object
Patch ActiveRecord::Inheritance::ClassMethods#finder_needs_type_condition?.
-
._install_active_record_model_schema_patch ⇒ Object
Patch ActiveRecord::ModelSchema::ClassMethods so worker Ractors do not write the
@table_name(and related) class ivars on the shared model class. - ._install_active_record_store_patch ⇒ Object
- ._install_active_storage_patch ⇒ Object
-
._install_active_support_error_reporter_patch ⇒ Object
Patch ActiveSupport module's @error_reporter class ivar (defined via
singleton_class.attr_accessor :error_reporterin active_support.rb:109) to not read from a worker Ractor. -
._install_activerecord_autosave_patch ⇒ Object
Patch ActiveRecord::AutosaveAssociation to redefine the
autosave_associated_records_for_<assoc>andvalidate_associated_records_for_<assoc>methods via string eval (compileddef, no captured binding) instead of Rails'define_method(&block). -
._install_activerecord_configurations_patch ⇒ Object
Patch ActiveRecord::Core.configurations / configurations= to route the raw
@@configurationsclass variable (which a non-main Ractor cannot read or write) through IsolatedExecutionState, with a shareable (deep-frozen) fallback for worker Ractors. -
._install_activerecord_connection_handler_patch ⇒ Object
Patch ActiveRecord::Base to route default_connection_handler through IES, and ensure connection_handler returns the per-Ractor handler.
-
._install_activerecord_db_config_handlers_patch ⇒ Object
Patch ActiveRecord::DatabaseConfigurations.db_config_handlers (a
singleton_class.attr_accessor, i.e. a class instance variable on the DatabaseConfigurations class) to route through IES with a shareable fallback. -
._install_activerecord_deduplicable_patch ⇒ Object
Patch Deduplicable::ClassMethods#registry to route the lazy class instance variable @registry through IES.
-
._install_activerecord_define_attribute_methods_patch ⇒ Object
Patch ActiveRecord::AttributeMethods::ClassMethods#define_attribute_methods to no-op in worker Ractors.
-
._install_activerecord_delegation_patch ⇒ Object
Patch ActiveRecord::Delegation.uncacheable_methods to route the lazy class instance variable @uncacheable_methods through IES.
-
._install_activerecord_find_by_cache_patch ⇒ Object
ActiveRecord::Base#cached_find_by_statementreads@find_by_statement_cache[connection.prepared_statements](a Hash whose values areConcurrent::Maps) and callscache.compute_if_absent(key). -
._install_activerecord_migration_patch ⇒ Object
Patch ActiveRecord::Migrator.migrations_paths (a singleton attr_accessor reading the
@migrations_pathsclass ivar) and ActiveRecord::Migration::CheckPending (the dev pending-migration middleware) to be Ractor-safe. -
._install_activerecord_model_classes_patch ⇒ Object
Register + run the model-class shareability patch (Blocker: AR model class lazy class-ivar initialization from workers).
-
._install_activerecord_model_schema_patch ⇒ Object
Patch ActiveRecord::ModelSchema::ClassMethods lazy class-ivar caches (
symbol_column_to_string,content_columns,column_defaults) to route through IsolatedExecutionState. -
._install_activerecord_module_attrs_patch ⇒ Object
Patch ActiveRecord module-level singleton_class.attr_accessor attributes (schema_cache_ignored_tables, database_cli, etc.) to route through IES with shareable fallbacks.
-
._install_activerecord_pool_config_patch ⇒ Object
Patch ActiveRecord::ConnectionAdapters::PoolConfig#initialize to skip writing to the INSTANCES ObjectSpace::WeakMap registry in non-main Ractors.
-
._install_activerecord_primary_key_patch ⇒ Object
Patch ActiveRecord::AttributeMethods::PrimaryKey#primary_key and #composite_primary_key? to not read the PRIMARY_KEY_NOT_SET constant.
-
._install_activerecord_query_constraints_patch ⇒ Object
Patch Persistence::ClassMethods#query_constraints_list and #has_query_constraints? to route the lazy @query_constraints_list class ivar through IES.
-
._install_activerecord_query_logs_patch ⇒ Object
Patch ActiveRecord::QueryLogs#tag_content.
-
._install_activerecord_query_transformers_patch ⇒ Object
Patch ActiveRecord.query_transformers to route through IES with a shareable fallback.
-
._install_activerecord_quoting_cache_patch ⇒ Object
Patch the adapter quoting caches (QUOTED_COLUMN_NAMES / QUOTED_TABLE_NAMES) to use per-Ractor storage instead of the unshareable
Concurrent::Mapconstants. -
._install_activerecord_reaper_patch ⇒ Object
Patch ConnectionPool::Reaper#run to no-op in non-main Ractors.
- ._install_activerecord_reflection_patch ⇒ Object
-
._install_activerecord_relation_delegate_cache_patch ⇒ Object
Blockers 3: ActiveRecord model classes cache relation-delegate classes in the
@relation_delegate_cacheclass instance variable (set inDelegateCache#initialize_relation_delegate_cache, activerecord/relation/delegation.rb:31-44). -
._install_activerecord_scope_patch ⇒ Object
Patch ActiveRecord::Scoping::Named::ClassMethods#scope to use string eval instead of define_method.
-
._install_activerecord_serialize_cast_value_patch ⇒ Object
Patch ActiveModel::Type::SerializeCastValue::ClassMethods #serialize_cast_value_compatible? to route the lazy class instance variable @serialize_cast_value_compatible through IES.
-
._install_activerecord_transaction_callbacks_patch ⇒ Object
In the shared :ractor graph, ActiveRecord model classes can end up with a nil
__callbacks(the class_attribute value can't be made shareable when it holds unshareable callback Procs, so the shim's shareable fallback returns nil). - ._install_arel_bind_block_patch ⇒ Object
-
._install_arel_visitor_dispatch_cache_patch ⇒ Object
Patch Arel::Visitors::Visitor.dispatch_cache to route through IES.
-
._install_as_attribute_types_cache_reset! ⇒ Object
ActiveStorage::Blob#metadatais declared viastore :metadata, coder: ActiveRecord::Coders::JSON, which registers aType::Serializedcast type throughdecorate_attributes(a lazily-memoizedPendingDecorator). -
._install_caching_key_generator_patch ⇒ Object
Patch ActiveSupport::CachingKeyGenerator#generate_key.
-
._install_callbacks_nil_safe_patch ⇒ Object
Patch ActiveSupport::Inflector::Inflections to not read @en_instance / @instance class ivars from a worker Ractor.
-
._install_controller_logger_patch ⇒ Object
loggeris delegated toconfig(a class_attribute) which loses its value when make_app_shareable! deep-freezes the shared graph, so a worker reads a nil/emptyconfigandloggerraises DelegationError ("logger delegated to config, but config is nil"). -
._install_controller_params_wrapper_patch ⇒ Object
ParamsWrapper#_wrapper_optionsis a class_attribute whose default value (ActionController::ParamsWrapper::Options.from_hash(format: [])) holds aMutex.newand back-references the controller/model klass, so it cannot be deep-frozen into the shared graph — its value is nil in a worker Ractor (see the#__class_attr__wrapper_optionsboot warning). -
._install_csrf_reset_patch ⇒ Object
In the shared :ractor graph, Devise's engine controllers (e.g. Devise::SessionsController) end up with a nil
csrf_token_storage_strategyat request time — the value is dropped when make_app_shareable! deep-freezes the app (RequestForgeryProtection sets it only on ActionController::Base.config, and the per-controller frozen config copy loses it). -
._install_devise_authenticatable_patch ⇒ Object
Devise::Models::Authenticatable::ClassMethods#devise_parameter_filter memoizes
@devise_parameter_filter ||= Devise::ParameterFilter.new(...)on the MODEL CLASS. -
._install_devise_failure_app_patch ⇒ Object
Devise::FailureApp.call memoizes its Rack endpoint in a class-level ivar (
@respond ||= action(:respond)). -
._install_devise_mailer_patch ⇒ Object
Devise.mailer reads
@@mailer_ref(a class variable) which raises IsolationError from a worker Ractor. - ._install_devise_url_helpers_patch ⇒ Object
-
._install_exception_wrapper_patch ⇒ Object
Patch ActionDispatch::ExceptionWrapper instance methods that read @@rescue_responses / @@rescue_templates class variables directly (bypassing the mattr_accessor reader the shim already reroutes through IES).
- ._install_execution_context_patch ⇒ Object
-
._install_flash_helpers_patch ⇒ Object
Patch the flash-type helper methods (
notice,alert, ...) defined byActionController::Metal::Flash#add_flash_typesviadefine_method(type) { request.flash[type] }. -
._install_hash_compute_if_absent_patch ⇒ Object
The shim's make_app_shareable! replaces Concurrent::Map instance variables (which are not Ractor-shareable) with plain Hashes so workers can read them.
-
._install_i18n_backend_patch ⇒ Object
Patch I18n::Backend::Simple::Implementation#translations and #store_translations.
-
._install_i18n_interpolation_patch ⇒ Object
Patch I18n.interpolate_hash.
-
._install_i18n_patch ⇒ Object
Patch I18n::Config's class-variable-backed accessors (default_locale, locale, backend, etc.) to not read @@cvars from a worker Ractor.
- ._install_inflector_patch ⇒ Object
-
._install_journey_routes_patch ⇒ Object
Make Journey route recognition work under kino :ractor.
-
._install_json_encoding_patch ⇒ Object
Patch ActiveSupport::JSON::Encoding.
-
._install_json_renderer_patch ⇒ Object
render json:,render xml:, andrender js:are dispatched to_render_with_renderer_json/_render_with_renderer_xml/_render_with_renderer_js— methods Rails defines viaActionController::Renderers.add(key, &block)→define_method(_render_with_renderer_method_name(key), &block)at boot (action_controller/metal/renderers.rb). - ._install_kaminari_config_patch ⇒ Object
- ._install_kaminari_page_method_patch ⇒ Object
-
._install_local_cache_patch ⇒ Object
Patch ActiveSupport::Cache::Strategy::LocalCache#local_cache_key.
-
._install_log_subscriber_patch ⇒ Object
Patch ActiveSupport::LogSubscriber.logger — a raw class ivar with lazy init (@logger ||= Rails.logger) that's WRITTEN at request teardown via flush_all!.
-
._install_loofah_patch ⇒ Object
Loofah (pulled in by rails-html-sanitizer for
sanitize/simple_format) memoizes@document_klasson its shared DocumentFragment classes. - ._install_lookup_context_patch ⇒ Object
- ._install_mail_patch ⇒ Object
- ._install_marcel_patch ⇒ Object
-
._install_messages_serializer_patch ⇒ Object
Patch ActiveSupport::Messages::SerializerWithFallback.
-
._install_mime_negotiation_worker_patch ⇒ Object
Patch ActionDispatch::Http::MimeNegotiation#formats and #negotiate_mime to be worker-Ractor safe.
-
._install_module_introspection_patch ⇒ Object
Patch Module#module_parent_name so a worker Ractor does not write the
@parent_nameclass ivar on a shared (non-frozen) module. -
._install_notifications_notifier_patch ⇒ Object
Patch ActiveSupport::Notifications.notifier to not read the @notifier class ivar from a worker Ractor.
- ._install_openssl_digest_patch ⇒ Object
- ._install_orm_adapter_patch ⇒ Object
-
._install_parameter_encoding_patch ⇒ Object
Patch ActionController::ParameterEncoding::ClassMethods#action_encoding_template to not read @_parameter_encodings (a raw class ivar) from a worker Ractor.
-
._install_path_registry_patch ⇒ Object
Patch ActionView::PathRegistry to not read its raw class ivars (@view_paths_by_class, @file_system_resolvers) from a worker Ractor.
- ._install_polymorphic_routes_patch ⇒ Object
- ._install_propshaft_patch ⇒ Object
-
._install_query_parser_patch ⇒ Object
ActionDispatch::QueryParser.each_pair returns
enum_for(:each_pair, s, separator)when called without a block. -
._install_rack_request_patch ⇒ Object
Patch Rack::Request's class-level attr_accessors (forwarded_priority, x_forwarded_proto_priority) to not read @ivars from a worker Ractor.
-
._install_rack_utils_patch ⇒ Object
Patch Rack::Utils singleton attr_accessors (default_query_parser, multipart_total_part_limit, multipart_file_limit) to not read @ivars from a worker Ractor.
-
._install_reloader_patch ⇒ Object
Patch ActiveSupport::Reloader#check! / #reloaded!.
-
._install_request_parameter_parsers_patch ⇒ Object
Patch ActionDispatch::Request.parameter_parsers (singleton attr_reader backed by @parameter_parsers) to not read the class ivar from a worker Ractor.
-
._install_secure_random_alphabets! ⇒ Object
ActiveSupportcore-ext definesSecureRandom::BASE36_ALPHABET/BASE58_ALPHABETas Arrays of non-frozen Strings. - ._install_template_handlers_patch ⇒ Object
- ._install_warden_hooks_patch ⇒ Object
-
._install_warden_serializer_patch ⇒ Object
Warden registers per-scope session serializers with
Warden::SessionSerializer.send(:define_method, method_name, &block)(warden-1.2.9/lib/warden/manager.rb:71). -
._install_warden_strategies_patch ⇒ Object
Patch Warden::Strategies#_strategies.
-
._install_with_empty_template_cache_patch ⇒ Object
Patch
ActionView::Base.with_empty_template_cache(action_view/base.rb:204) to a block-freedef. -
._make_mail_constants_shareable! ⇒ Object
Walk every module/class under
Mailand make each un-shareable constant into; leaf values (Regexp, Array, Hash, String, …) are frozen via Ractor.make_shareable. - ._maybe_apply_active_storage_patch ⇒ Object
- ._patch_active_model_type_default_value! ⇒ Object
- ._patch_rails_module_body(mod) ⇒ Object
-
._precompute_propshaft!(app) ⇒ Object
Warm every Propshaft Assembly/LoadPath lazy ivar in MAIN (before the app is frozen).
-
._reassign_shareable_const(name, value) ⇒ Object
Reassign a constant on RactorRailsShim with a new shareable value, silencing the "already initialized constant" warning that const_set emits when the constant was previously defined.
-
._rebuild_activerecord_model_snapshots! ⇒ Object
(Re)build the per-model shareable snapshots that workers read instead of un-shareable class ivars: AR_PRIMARY_KEYS_SHAREABLE (model name -> primary key) and SHAREABLE_PENDING_ATTR_MODS (model object_id -> attribute macro modifications).
-
._register_for_fallback(mod_name, sym, key, default) ⇒ Object
Register a mattr accessor so _build_shareable_fallback! can capture + make shareable the main-ractor value at prepare_for_ractors! time.
-
._register_patch(name, *tested_segments) ⇒ Object
Record that a patch was developed/tested against the given Rails version segments.
-
._seed_active_storage_prefix! ⇒ Object
Seed the shareable ActiveStorage table-name prefix/suffix constants from the (main-ractor)
ActiveStorage.table_name_prefixvalue. -
._seed_mattr_default(key, default) ⇒ Object
Store the definition-time default for a mattr accessor in the runtime registry.
-
._share_active_record_internals! ⇒ Object
ActiveRecord's internal helper classes (the *Clause classes used while building a relation's Arel) cache a frozen "empty" singleton in a class instance variable via
@empty ||= new(...).freeze(e.g. ActiveRecord::Relation::WhereClause#empty). -
._share_model_classes! ⇒ Object
ActiveRecord model classes lazily initialize many class instance variables on first use (e.g. @table_name, @arel_table, @predicate_builder, @columns_hash, @attribute_methods_module) via
@ivar ||= compute. -
._share_relation_delegate_caches! ⇒ Object
Make every loaded AR model class's @relation_delegate_cache shareable.
-
._shareable_ivar_replacement(v) ⇒ Object
- Monitor/Mutex -> NoOpLock (never contended post-boot) - Concurrent::Map -> frozen Hash - else -> Ractor.make_shareable; if that fails (statement caches etc.), a frozen empty container of the same kind so the worker reads a shareable value (cold cache in workers; slower, correct).
-
._swallow(label) ⇒ Object
Swallow an exception raised by the block, optionally logging it when
debug?is on. -
._version_mismatch(message) ⇒ Object
Apply the configured policy to a mismatch message.
-
._warm_journey_routes! ⇒ Object
Warm + cache
@ast/@simulatoron the live Routes graph. -
._warm_path_patterns!(routes) ⇒ Object
Pre-compute every Journey::Path::Pattern's lazy memoized ivars (@required_names, @optional_names, @offsets, @re, @requirements_for_missing_keys_check) on the LIVE (unfrozen) pattern, before Ractor.make_shareable freezes the graph.
-
.applicable_patches ⇒ Object
Report which registered patches apply to the runtime Rails version (and which were skipped because they're untested on it).
-
.autoload_install! ⇒ Object
Require this gem and the patches auto-install IF Rails is loaded.
-
.capture_app_constants! ⇒ Object
Capture a frozen name -> object map for every constant the application's Zeitwerk loaders manage.
- .debug=(value) ⇒ Object
-
.debug? ⇒ Boolean
When true, swallowed exceptions in freeze/shareability paths are reported to $stderr so a worker Ractor that later crashes on an unshareable value has a traceable cause.
-
.fix_url_helpers_singleton_routes! ⇒ Object
The module-level singleton
_routes(set viaredefine_singleton_methodin the gem'sincludedblock) is still adefine_methodblock. -
.init_worker_ar_connections! ⇒ Object
Worker-Ractor hook: create a fresh ConnectionHandler and establish connections from the captured configurations snapshot.
-
.install ⇒ Object
Install all the patches.
- .install_class_attribute ⇒ Object
- .install_execution_wrapper ⇒ Object
- .install_mattr_accessor ⇒ Object
-
.install_rails_load_hook ⇒ Object
Defer the Rails-module patch until Rails is defined.
-
.install_rails_module ⇒ Object
Patch the Rails module's class-level accessors (Rails.application, Rails.env, Rails.cache, etc.) to route through IsolatedExecutionState.
- .install_route_helpers_patch ⇒ Object
-
.install_rubygems ⇒ Object
Installed at
install(boot) time. - .install_url_helpers_patch ⇒ Object
- .install_zeitwerk_registry ⇒ Object
- .installed? ⇒ Boolean
-
.make_app_shareable!(app) ⇒ Object
Public API: make Rails.application shareable across Ractors.
-
.make_constant_shareable!(const_path) ⇒ Object
Resolve a constant path string to a value, and if it exists and is not already shareable, replace it with its shareable (deep-frozen) version.
-
.patch_class_attribute! ⇒ Object
The actual patch.
- .patch_execution_wrapper! ⇒ Object
-
.patch_rails_module!(mod) ⇒ Object
The actual Rails-module patch.
- .patch_rubygems! ⇒ Object
- .patch_zeitwerk_registry! ⇒ Object
-
.prepare_for_ractors! ⇒ Object
Public API: run after Rails.application.initialize! and BEFORE spawning worker Ractors.
-
.shareable_constants ⇒ Object
Public reader for the SHAREABLE_CONSTANTS registry.
-
.snapshot_gem_paths! ⇒ Object
Called from prepare_for_ractors! (post-boot, main Ractor) so the snapshot reflects Bundler-configured gem paths.
-
.snapshot_query_logs! ⇒ Object
Capture the QueryLogs handlers/formatter as a shareable snapshot for workers (called post-boot, main Ractor, in prepare_for_ractors!).
-
.storage ⇒ Object
The active storage implementation.
- .thread_mode=(value) ⇒ Object
-
.thread_mode? ⇒ Boolean
Install all the patches.
-
.version_policy ⇒ Object
Policy for version mismatches.
- .version_policy=(value) ⇒ Object
-
.worker_app!(frozen_app) ⇒ Object
Build the shareable Rack app handed to kino.
-
.worker_ar_init(app) ⇒ Object
Wrap
appso every worker Ractor initializes its ActiveRecord connections on first request.
Class Attribute Details
._abstract_registry ⇒ Object
Accessor for the abstract-controller registry (written by abstract! in main, read by abstract? in workers). Reassigned to a shareable frozen Hash at prepare_for_ractors! time.
68 69 70 |
# File 'lib/ractor_rails_shim/patches/core.rb', line 68 def _abstract_registry @_abstract_registry end |
._view_context_fallback ⇒ Object
Returns the value of attribute _view_context_fallback.
70 71 72 |
# File 'lib/ractor_rails_shim/patches/core.rb', line 70 def _view_context_fallback @_view_context_fallback end |
._view_context_registry ⇒ Object
Returns the value of attribute _view_context_registry.
69 70 71 |
# File 'lib/ractor_rails_shim/patches/core.rb', line 69 def _view_context_registry @_view_context_registry end |
.storage_strategy ⇒ Object
287 288 289 290 |
# File 'lib/ractor_rails_shim/foundation/storage_strategy.rb', line 287 def storage_strategy return @storage_strategy if defined?(@storage_strategy) RactorRailsShim::RunMode.thread? ? StorageStrategy::Thread : StorageStrategy::Ractor end |
Class Method Details
._apply_active_storage_blob_patch ⇒ Object
ActiveStorage::Blob is loaded after the attached macros, so it is
patched in a later TracePoint event. Both methods here use un-shareable
blocks compiled in the main Ractor, which raise "defined with an
un-shareable Proc in a different Ractor" when a worker uploads an
attachment; reimplement without blocks.
369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 |
# File 'lib/ractor_rails_shim/patches/active_storage.rb', line 369 def _apply_active_storage_blob_patch return unless defined?(::ActiveStorage::Blob) ::ActiveStorage::Blob.prepend(ActiveStorageAttachedPatch::BlobChecksumPatch) ::ActiveStorage::Blob.singleton_class.prepend(ActiveStorageAttachedPatch::BlobBuildPatch) # Ensure `ActiveStorage::Blob#metadata` is a serialized store attribute even # when the class is freshly autoloaded in a worker Ractor's empty constant # namespace. `store :metadata` registers a `Type::Serialized` decorator via # `decorate_attributes`, but the decorator is only applied to attributes that # already exist in `cast_types` — and `cast_types` is built lazily from the # DB `columns_hash`. If the class is loaded before the worker's DB connection # is established, `columns_hash` (and thus `cast_types`) is empty, so the # `:metadata` decorator is silently skipped and `store_accessor_for` later # raises "the column 'metadata' has not been configured as a store". Force # the serialized type for `:metadata` directly so `write_store_attribute` # works in the worker regardless of load-order. ::ActiveStorage::Blob.singleton_class.prepend(ActiveStorageAttachedPatch::BlobMetadataTypePatch) ::ActiveStorage::Blob.prepend(ActiveStorageAttachedPatch::ActiveStorageServicePatch) @_as_patched_blob = true end |
._apply_active_storage_macro_patch ⇒ Object
337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 |
# File 'lib/ractor_rails_shim/patches/active_storage.rb', line 337 def _apply_active_storage_macro_patch return unless defined?(::ActiveStorage::Attached::Model::ClassMethods) ::ActiveStorage::Attached::Model::ClassMethods.prepend(ActiveStorageAttachedPatch) # `ActiveStorage.table_name_prefix` / `table_name_suffix` are declared via # `mattr_accessor` (railties/engine.rb) whose reader is an un-shareable # `define_method` Proc when invoked from a worker Ractor. They feed # `ActiveRecord::ModelSchema#full_table_name_prefix`, which # `compute_table_name` calls for any ActiveStorage model whose explicit # `table_name=` (set in main) is invisible to a worker's per-Ractor IES — # so without this fix a worker raises "defined with an un-shareable Proc". # Redefine the readers as shareable string-eval `def`s that return the # shareable ACTIVE_STORAGE_PREFIX / ACTIVE_STORAGE_SUFFIX constants. Those # constants are seeded with the real values in main at prepare_for_ractors! # time (see `_seed_active_storage_prefix!`), so workers read the correct # prefix ("active_storage_") without crossing the Ractor boundary. if defined?(::ActiveStorage) ::ActiveStorage.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def table_name_prefix; RactorRailsShim::ACTIVE_STORAGE_PREFIX; end def table_name_suffix; RactorRailsShim::ACTIVE_STORAGE_SUFFIX; end RUBY end @_as_patched_macros = true _register_patch :active_storage, "8.1" end |
._apply_activerecord_autosave_patch ⇒ Object
2520 2521 2522 2523 2524 2525 2526 2527 2528 2529 2530 2531 2532 2533 2534 2535 2536 2537 2538 2539 2540 2541 2542 2543 2544 2545 2546 2547 2548 2549 2550 2551 2552 2553 2554 2555 2556 2557 2558 2559 2560 2561 2562 2563 2564 2565 2566 2567 2568 2569 2570 2571 2572 2573 2574 2575 2576 2577 2578 2579 2580 2581 2582 2583 2584 2585 2586 2587 2588 2589 2590 2591 2592 2593 2594 2595 2596 2597 2598 2599 2600 2601 2602 2603 2604 2605 2606 2607 2608 2609 2610 2611 2612 2613 2614 2615 2616 2617 2618 2619 2620 2621 2622 2623 2624 2625 2626 2627 2628 2629 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 2520 def _apply_activerecord_autosave_patch return unless defined?(::ActiveRecord::AutosaveAssociation::ClassMethods) mod = ::ActiveRecord::AutosaveAssociation::ClassMethods # --- save callbacks --- mod.alias_method(:_rrs_orig_add_autosave_association_callbacks, :add_autosave_association_callbacks) unless mod.method_defined?(:_rrs_orig_add_autosave_association_callbacks) mod.module_eval do def add_autosave_association_callbacks(reflection) # Call the original: registers the callback (after_create/save/etc.) # and creates the unshareable define_method method. _rrs_orig_add_autosave_association_callbacks(reflection) # Immediately redefine the method via string eval (no captured # binding). The reflection is stored in the shareable registry. save_method = :"autosave_associated_records_for_#{reflection.name}" key = [self.name.to_s, save_method.to_s] _rrs_store_autosave_reflection(key, reflection) if reflection.collection? body_call = "save_collection_association(_rrs_autosave_reflection(#{key.inspect}))" elsif reflection.has_one? body_call = "save_has_one_association(_rrs_autosave_reflection(#{key.inspect}))" else body_call = "throw(:abort) if save_belongs_to_association(_rrs_autosave_reflection(#{key.inspect})) == false" end class_eval <<-RUBY, __FILE__, __LINE__ + 1 def #{save_method} @_already_called ||= {} return true if @_already_called[#{save_method.inspect}] result = true begin @_already_called[#{save_method.inspect}] = true #{body_call} ensure @_already_called[#{save_method.inspect}] = false end result end RUBY end end # --- validation callbacks --- mod.alias_method(:_rrs_orig_define_autosave_validation_callbacks, :define_autosave_validation_callbacks) unless mod.method_defined?(:_rrs_orig_define_autosave_validation_callbacks) mod.module_eval do def define_autosave_validation_callbacks(reflection) # Call the original: registers the validate callback + creates the # unshareable define_method method. _rrs_orig_define_autosave_validation_callbacks(reflection) validation_method = :"validate_associated_records_for_#{reflection.name}" # Only redefine if the original actually created the method. return unless method_defined?(validation_method, false) key = [self.name.to_s, validation_method.to_s] _rrs_store_autosave_reflection(key, reflection) if reflection.collection? val_method = :validate_collection_association elsif reflection.has_one? val_method = :validate_has_one_association else val_method = :validate_belongs_to_association end class_eval <<-RUBY, __FILE__, __LINE__ + 1 def #{validation_method} @_already_called ||= {} return true if @_already_called[#{validation_method.inspect}] result = true begin @_already_called[#{validation_method.inspect}] = true send(#{val_method.inspect}, _rrs_autosave_reflection(#{key.inspect})) ensure @_already_called[#{validation_method.inspect}] = false end result end RUBY end end # --- helper methods for registry access --- mod.module_eval do # Store a reflection in the shareable registry. Called at boot time # (main Ractor) during association declaration. def _rrs_store_autosave_reflection(key, reflection) cur = RactorRailsShim::SHAREABLE_AUTOSAVE_REFLECTIONS.dup cur[key] = reflection RactorRailsShim.send(:remove_const, :SHAREABLE_AUTOSAVE_REFLECTIONS) if RactorRailsShim.const_defined?(:SHAREABLE_AUTOSAVE_REFLECTIONS, false) RactorRailsShim.const_set(:SHAREABLE_AUTOSAVE_REFLECTIONS, Ractor.make_shareable(cur)) end # Look up a reflection from the shareable registry. Called at runtime # (worker Ractor) inside the string-eval'd method body. def _rrs_autosave_reflection(key) RactorRailsShim::SHAREABLE_AUTOSAVE_REFLECTIONS[key] end end end |
._apply_activerecord_reflection_patch ⇒ Object
307 308 309 310 311 312 313 314 315 |
# File 'lib/ractor_rails_shim/patches/activerecord_reflection.rb', line 307 def _apply_activerecord_reflection_patch return unless defined?(::ActiveRecord::Reflection::AbstractReflection) ::ActiveRecord::Reflection::AbstractReflection.prepend(ReflectionAbstractPatch) ::ActiveRecord::Reflection::MacroReflection.prepend(ReflectionMacroPatch) ::ActiveRecord::Reflection::AssociationReflection.prepend(ReflectionAssociationPatch) ::ActiveRecord::Reflection::ThroughReflection.prepend(ReflectionThroughPatch) _register_patch :activerecord_reflection, "8.1" end |
._apply_activerecord_scope_patch ⇒ Object
2301 2302 2303 2304 2305 2306 2307 2308 2309 2310 2311 2312 2313 2314 2315 2316 2317 2318 2319 2320 2321 2322 2323 2324 2325 2326 2327 2328 2329 2330 2331 2332 2333 2334 2335 2336 2337 2338 2339 2340 2341 2342 2343 2344 2345 2346 2347 2348 2349 2350 2351 2352 2353 2354 2355 2356 2357 2358 2359 2360 2361 2362 2363 2364 2365 2366 2367 2368 2369 2370 2371 2372 2373 2374 2375 2376 2377 2378 2379 2380 2381 2382 2383 2384 2385 2386 2387 2388 2389 2390 2391 2392 2393 2394 2395 2396 2397 2398 2399 2400 2401 2402 2403 2404 2405 2406 2407 2408 2409 2410 2411 2412 2413 2414 2415 2416 2417 2418 2419 2420 2421 2422 2423 2424 2425 2426 2427 2428 2429 2430 2431 2432 2433 2434 2435 2436 2437 2438 2439 2440 2441 2442 2443 2444 2445 2446 2447 2448 2449 2450 2451 2452 2453 2454 2455 2456 2457 2458 2459 2460 2461 2462 2463 2464 2465 2466 2467 2468 2469 2470 2471 2472 2473 2474 2475 2476 2477 2478 2479 2480 2481 2482 2483 2484 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 2301 def _apply_activerecord_scope_patch return unless defined?(::ActiveRecord::Scoping::Named::ClassMethods) # Shareable (frozen) registry of scope source code: # { "Model" => { :recent => [body_source, [params]] } }. It MUST be a # frozen shareable object so worker Ractors can read it from the shared # app graph. But scopes register their bodies during boot eager-load # (after this patch installs, before the graph freezes), so each write # rebuilds the frozen Hash atomically via const_set. Cost is negligible # (scopes are defined only at boot). unless RactorRailsShim.const_defined?(:SCOPE_SOURCE_CODES) RactorRailsShim.const_set(:SCOPE_SOURCE_CODES, Ractor.make_shareable({})) end mod = ::ActiveRecord::Scoping::Named::ClassMethods mod.module_eval do alias_method :_rrs_orig_scope, :scope unless method_defined?(:_rrs_orig_scope) def scope(name, body = nil, &block) unless body.respond_to?(:call) raise ArgumentError, "The scope body needs to be callable." end if dangerous_class_method?(name) raise ArgumentError, "You tried to define a scope named \"#{name}\" " \ "on the model \"#{self.name}\", but Active Record already defined " \ "a class method with the same name." end if method_defined_within?(name, ::ActiveRecord::Relation) raise ArgumentError, "You tried to define a scope named \"#{name}\" " \ "on the model \"#{self.name}\", but ActiveRecord::Relation already defined " \ "an instance method with the same name." end extension = Module.new(&block) if block # Extract the body's source code from the lambda's source_location # so workers can eval it without calling the original Proc. model_name_str = self.name.to_s scope_name_str = name.to_s body_source = nil if body.respond_to?(:source_location) file, line = body.source_location if file && line begin source_lines = File.readlines(file) raw_line = source_lines[line - 1]&.strip if raw_line # Extract the body from patterns like: # scope :name, -> { ... } # scope(:name, -> { ... }) # scope :name, ->(arg) { ... } if raw_line =~ /->\s*(?:\([^)]*\))?\s*\{/ # Find the matching closing brace full_source = raw_line depth = 0 start_idx = raw_line.index("{") if start_idx (start_idx...raw_line.length).each do |i| case raw_line[i] when "{" depth += 1 when "}" depth -= 1 if depth == 0 full_source = raw_line[start_idx + 1..i - 1].strip break end end end end # Also check multi-line if the brace wasn't closed if depth > 0 ((line)..(line + 10)).each do |ln| next_line = source_lines[ln]&.strip next unless next_line full_source += " " + next_line next_line.each_char do |c| depth += 1 if c == "{" depth -= 1 if c == "}" if depth == 0 # Trim the extra } full_source = full_source[0..-(next_line.length - next_line.rindex("}") + 2)] break end end break if depth <= 0 end end body_source = full_source end end rescue StandardError nil end end end # Store the body_source + parameter names in the shareable constant # (cross-Ractor). Parameter names let workers bind the call's args # (scopes like `by_title(q)`) without ever referencing the caller's # main-Ractor locals. if body_source param_names = if body.respond_to?(:parameters) body.parameters.map { |p| p[1] }.compact else [] end cur = RactorRailsShim.const_defined?(:SCOPE_SOURCE_CODES) ? RactorRailsShim::SCOPE_SOURCE_CODES : {} cur = cur.dup cur[model_name_str] ||= {} cur[model_name_str] = cur[model_name_str].dup cur[model_name_str][scope_name_str.to_sym] = [body_source, param_names] # Reassigning a constant that already exists warns ("already # initialized constant"); drop the old binding first so each scope # registration stays silent. RactorRailsShim.send(:remove_const, :SCOPE_SOURCE_CODES) if RactorRailsShim.const_defined?(:SCOPE_SOURCE_CODES, false) RactorRailsShim.const_set(:SCOPE_SOURCE_CODES, Ractor.make_shareable(cur)) end # Also store extension if present. Scope extensions are Modules, which # are not shareable across Ractors, so keep them in per-Ractor storage # (available in the Ractor that defined the scope; workers without the # extension simply skip it — acceptable for the common no-extension case). if extension Ractor.current[:"rrs_scope_ext_#{model_name_str}_#{scope_name_str}"] = extension end # Define via string eval (compiled def, not define_method block). # # CRITICAL: the scope body must be evaluated from a STRING, not a # block. A literal block `{ order(...) }` written here is compiled in # the main Ractor (where this method is defined) and calling it from a # worker raises "defined with an un-shareable Proc in a different # Ractor". `instance_eval(string)` compiles the string at CALL time, in # the worker Ractor, so it is shareable. Scope args are bridged onto a # transient ivar on the relation (`@_rrs_scope_args`) so the body can # read them with `self` bound to the relation. if body_source singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def #{name}(*args) store = RactorRailsShim::SCOPE_SOURCE_CODES[self.name] info = store && store[:"#{scope_name_str}"] ext_key = :"rrs_scope_ext_\#{self.name}_#{scope_name_str}" ext = Ractor.current[ext_key] return super(*args) unless info body_source, param_names = info bind = param_names.each_with_index.map { |n, i| "\#{n} = @_rrs_scope_args[\#{i}]" }.join("; ") code = bind.empty? ? body_source : "\#{bind}; \#{body_source}" # Capture `all` once: it is a method call that returns a fresh # relation each time, so setting the args ivar on one instance # and evaluating `code` on another would leave @_rrs_scope_args # nil on the eval target (raising "undefined method '[]' for # nil"). Use the same relation for both. rel = all rel.instance_variable_set(:@_rrs_scope_args, args) scope = rel.instance_eval(code) scope = scope.extending(ext) if ext scope end RUBY else # Fallback: use the original define_method approach (works in main only). if body.respond_to?(:to_proc) singleton_class.define_method(name) do |*args| scope = all._exec_scope(*args, &body) scope = scope.extending(extension) if extension scope end else singleton_class.define_method(name) do |*args| scope = body.call(*args) || all scope = scope.extending(extension) if extension scope end end singleton_class.send(:ruby2_keywords, name) end generate_relation_method(name) end end end |
._capture_ar_configurations! ⇒ Object
170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 170 def _capture_ar_configurations! return if @_ar_configs_captured @_ar_configs_captured = true return unless defined?(::ActiveRecord::Base) begin # Build a plain Hash snapshot of every db config keyed by # [env_name][config_name] => config_hash. Use # ActiveRecord::Base.configurations.configs_for (returns DbConfig # objects with .name, .env_name, .configuration_hash) rather than # Rails.application.config.database_configuration (a legacy Hash whose # shape differs between single-config apps (flat Hash = the config # itself) and multi-config apps (nested Hash of name => config)). cfgs = ::ActiveRecord::Base.configurations snapshot = {} if cfgs.respond_to?(:configs_for) cfgs.configs_for.each do |dc| env_name = dc.env_name name = dc.name || "primary" hash = dc.configuration_hash next unless hash.is_a?(::Hash) snapshot[env_name] ||= {} snapshot[env_name][name] = hash.reject { |_k, v| v.nil? } end end # Fallback: legacy database_configuration Hash (env => { name => config } # OR env => flat config). Used if configs_for is unavailable. if snapshot.empty? raw = ::Rails.application.config.database_configuration rescue {} raw.each do |env_name, env_configs| next unless env_configs.is_a?(::Hash) snapshot[env_name] ||= {} if env_configs.key?("adapter") || env_configs.key?(:adapter) # Flat config: the env value IS the "primary" config itself. snapshot[env_name]["primary"] = env_configs.reject { |_k, v| v.nil? } else # Nested: env => { name => config } env_configs.each do |name, config| next unless config.is_a?(::Hash) snapshot[env_name][name] = config.reject { |_k, v| v.nil? } end end end end snapshot.freeze Ractor.make_shareable(snapshot) _reassign_shareable_const(:AR_CONFIGURATIONS_SNAPSHOT, snapshot) rescue StandardError => e # Best-effort; if we can't capture configs, workers won't be able # to auto-init connections. They can call init_worker_ar_connections! # manually with explicit configs. end end |
._capture_dependent_associations! ⇒ Object
Capture every AR model's dependent: associations into the shareable
SHAREABLE_DEPENDENT_ASSOCIATIONS table so worker Ractors can replay them
(see StorageStrategy::Ractor#replay_callbacks! / the :destroy branch of
the nil-safe run_callbacks patch). The dependent: lambda filter is an
unshareable Proc, so it can't live in the frozen, shared __callbacks
chain; instead we record (model_name => [type:, macro:]) and
re-drive record.association(name).handle_dependency in workers. Runs in
the main Ractor after eager-load (reflections + descendants are ready).
530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 530 def _capture_dependent_associations! return unless defined?(::ActiveRecord::Base) table = {} classes = [::ActiveRecord::Base] classes.concat(::ActiveRecord::Base.descendants) rescue nil classes.each do |klass| name = klass.name next unless name next if klass.respond_to?(:abstract_class?) && klass.abstract_class? begin next unless klass.respond_to?(:reflect_on_all_associations, true) klass.reflect_on_all_associations.each do |refl| dep = refl.[:dependent] next unless dep (table[name] ||= []) << { name: refl.name.to_sym, type: dep.to_sym, macro: refl.macro, } end rescue StandardError nil end end _reassign_shareable_const( :SHAREABLE_DEPENDENT_ASSOCIATIONS, Ractor.make_shareable(table) ) end |
._check_version_support ⇒ Object
Verify the runtime matches the versions the shim was developed against.
The shim's patches target specific Rails 8.1 class layouts and Ruby 4.0
Ractor semantics. On other versions, the patches may silently miss or
break things. Behavior on mismatch is governed by version_policy:
:warn (default) print a warning to $stderr, proceed anyway
:strict raise RactorRailsShim::UnsupportedVersionError
:off silent (for advanced users / experimentation)
Ruby mismatch always warns (Ractor semantics are not stable across majors); Rails mismatch uses the policy. This is real version detection (Gem::Version-based), not a string-prefix compare, so pre-release and patch versions sort correctly.
257 258 259 |
# File 'lib/ractor_rails_shim/patches/core.rb', line 257 def _check_version_support RactorRailsShim::VersionPolicy.check_version_support end |
._class_attr_methods(method_name, namespaced_name, missing_default) ⇒ Object
Build the reader/writer pair for one method name. ONE body for both
run modes — the selected RactorRailsShim.storage_strategy (set once
at install from RunMode.thread?) decides the lookup/store backend.
method_name is the def name (namespaced or public); namespaced_name
is the attribute Symbol (e.g. :mimes_for_respond_to) — the strategy
derives a PER-RECEIVER storage key from it at call time so each class
keeps its own class_attribute value (and inherits via an ancestor
walk), instead of all classes sharing one slot keyed by the declaring
module. missing_default is the inlined missing-slot default
expression (string of Ruby source — only the Thread strategy consults
it; the Ractor strategy relies on IES + SHAREABLE_FALLBACK +
CLASS_ATTR_VALUES).
162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 |
# File 'lib/ractor_rails_shim/patches/class_attribute.rb', line 162 def _class_attr_methods(method_name, namespaced_name, missing_default) # `resolved_key` is a literal symbol baked into the generated method # source (interpolated at install time, not per read). The hot reader # path reads the resolved value from IES under this literal key, indexed # by the receiver's object_id — both are allocation-free, so the # regression guard (ShimSpec#test_0009) holds. The ancestor walk lives in # `lookup_by_attr` and only runs on the cold (first) read per receiver. rkey = :"ractor_rails_shim_resolved_#{namespaced_name}" <<~RUBY def #{method_name} RactorRailsShim.storage_strategy.lookup_resolved(self, :#{namespaced_name}, #{missing_default}, :#{rkey}) end def #{method_name}=(new_value) RactorRailsShim.storage_strategy.store_resolved(self, :#{namespaced_name}, new_value, :#{rkey}) new_value end RUBY end |
._devise_mapping_replacement(proc_obj, _parent) ⇒ Object
Build a shareable replacement for a Devise scope constraint. The original Proc (devise/rails/routes.rb:363) does:
request.env["devise.mapping"] = Devise.mappings[scope]
true
The scope is captured in the Proc's binding. We call the original Proc once in main with a mock request to capture the mapping, then make it shareable and wrap it in a DeviseMappingCallable.
177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 |
# File 'lib/ractor_rails_shim/patches/warden.rb', line 177 def _devise_mapping_replacement(proc_obj, _parent) mock_env = { "devise.mapping" => nil } mock_req = Struct.new(:env).new(mock_env) begin proc_obj.call(mock_req) rescue StandardError end mapping = mock_env["devise.mapping"] if mapping mapping = _devise_mapping_snapshot(mapping) end if mapping DeviseMappingCallable.new(mapping) else CallableConst.new(true) end end |
._devise_mapping_snapshot(mapping) ⇒ Object
160 161 162 |
# File 'lib/ractor_rails_shim/patches/callables.rb', line 160 def _devise_mapping_snapshot(mapping) _swallow("devise mapping snapshot") { DeviseMappingSnapshot.new(mapping) } end |
._find_files_server(mw) ⇒ Object
Find the Rack::Files (asset) server in the middleware chain, used by make_app_shareable! when replacing the Rack::Head#@app lambda (whose binding receiver is the Rack::Files instance). Moved here from make_shareable.rb so the Rack concern's pieces live together.
149 150 151 152 153 154 155 156 157 158 |
# File 'lib/ractor_rails_shim/patches/rack.rb', line 149 def _find_files_server(mw) cur = mw while cur if cur.class.name == "ActionDispatch::Static" return cur.instance_variable_get(:@file_server) end cur = cur.instance_variable_get(:@app) rescue nil end nil end |
._fix_blob_metadata_type! ⇒ Object
Force-rebuild ActiveStorage::Blob's _default_attributes and
@attribute_types in MAIN so the :metadata attribute uses the correct
Type::Serialized before the graph is frozen. The store :metadata
decorator registers a Type::Serialized via decorate_attributes, but
@attribute_types is memoized separately and isn't invalidated when the
decorator runs — so it keeps a stale Type::Value. read_attribute uses
the attribute set (not type_for_attribute), so the stale type makes
read_attribute(:metadata) return a raw String instead of a deserialized
Hash, breaking write_store_attribute and identified= in workers.
Must run in the main Ractor (writes class ivars).
459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 |
# File 'lib/ractor_rails_shim/patches/active_storage.rb', line 459 def return unless defined?(::ActiveStorage::Blob) && ::Ractor.main? blob_cls = ::ActiveStorage::Blob # Directly fix the attribute set: replace the :metadata attribute's type # with Type::Serialized. The `store :metadata` decorator was applied # once during eager-load but only to the (now stale) `_default_attributes` # cache. Re-register the decorator and rebuild from scratch. attrs = blob_cls._default_attributes = attrs[:metadata] if && !.type.is_a?(::ActiveRecord::Type::Serialized) # Re-run the serialize decorator for :metadata. The original `store # :metadata, coder: ActiveRecord::Coders::JSON` uses # `build_column_serializer` which instantiates `Coders::JSON.new` # (the class itself doesn't respond to dump/load). Reproduce that here. coder = ::ActiveRecord::Coders::JSON.new ind_coder = ::ActiveRecord::Store::IndifferentCoder.new(:metadata, coder) = ::ActiveRecord::Type::Serialized.new(.type, ind_coder) # Fix BOTH the string and symbol key entries (the attribute set has # both "metadata" and :metadata — read_attribute uses the string key, # which has the wrong Type::Text). attrs[:metadata] = .with_type() string_attr = attrs["metadata"] if string_attr && !string_attr.type.is_a?(::ActiveRecord::Type::Serialized) attrs["metadata"] = string_attr.with_type() end # Clear the stale @attribute_types cache so it's rebuilt with the # corrected type from the updated attribute set. blob_cls.remove_instance_variable(:@attribute_types) if blob_cls.instance_variable_defined?(:@attribute_types) end end |
._freeze_class_ivars!(owner) ⇒ Object
Make every unshareable class ivar on owner shareable (deep-freeze) and
write it back. A class ivar holding a shareable value is readable from a
worker Ractor. Monitor/Mutex->NoOpLock; Concurrent::Map->frozen Hash;
values that can't be frozen (Procs, TypeMap) are left as-is.
762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 762 def _freeze_class_ivars!(owner) begin owner.instance_variables.each do |iv| v = owner.instance_variable_get(iv) rescue nil next unless v next if Ractor.shareable?(v) replacement = _shareable_ivar_replacement(v) next unless replacement begin owner.instance_variable_set(iv, replacement) rescue StandardError => e # frozen owner — leave as-is end end rescue StandardError => e end end |
._freeze_journey_visitors! ⇒ Object
Journey's routing visitors are stored as instance singletons in class
constants (e.g. ActionDispatch::Journey::Visitors::Each::INSTANCE).
Worker Ractors read these constants while recognizing routes
(Node#each → Each::INSTANCE.accept, Path::Pattern#match →
offsets → node.each), and a non-frozen instance is NOT a shareable
object → Ractor::IsolationError: can not access non-shareable objects in constant ...::Each::INSTANCE by non-main Ractor. The visitor instances
are stateless, so freezing them makes them shareable with no behavior
change. The same applies to the DISPATCH_CACHE Hashes the visitor
accept/visit dispatch through. These constants are NOT reachable
from the frozen app graph (Ractor.make_shareable never touches them), so
we must freeze them explicitly here (in main, before workers spawn).
680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 |
# File 'lib/ractor_rails_shim/patches/action_dispatch.rb', line 680 def _freeze_journey_visitors! return unless defined?(::ActionDispatch::Journey::Visitors) v = ::ActionDispatch::Journey::Visitors [[:Each, :INSTANCE], [:String, :INSTANCE], [:Dot, :INSTANCE]].each do |klass, const| mod = v.const_get(klass) rescue nil next unless mod && mod.const_defined?(const) inst = mod.const_get(const) inst.freeze if inst.respond_to?(:freeze) && !inst.frozen? end [[:Visitor, :DISPATCH_CACHE], [:FunctionalVisitor, :DISPATCH_CACHE]].each do |klass, const| mod = v.const_get(klass) rescue nil next unless mod && mod.const_defined?(const) cache = mod.const_get(const) cache.freeze if cache.respond_to?(:freeze) && !cache.frozen? end # GTG::Builder::DUMMY_END_NODE is a non-shareable instance referenced when # a worker Ractor rebuilds the route simulator (e.g. if the warmed # @simulator cache is missing on the frozen graph). Make it Ractor-shareable # (deep-freeze) so workers can read the constant without # Ractor::IsolationError. It is a stateless dummy node, so this is # behavior-preserving. if defined?(::ActionDispatch::Journey::GTG::Builder) && ::ActionDispatch::Journey::GTG::Builder.const_defined?(:DUMMY_END_NODE) node = ::ActionDispatch::Journey::GTG::Builder.const_get(:DUMMY_END_NODE) _swallow("make journey dummy end node shareable") { Ractor.make_shareable(node) } end end |
._freeze_mime_negotiation! ⇒ Object
ActionDispatch::Http::MimeNegotiation holds module-level constants
(e.g. RESCUABLE_MIME_FORMAT_ERRORS, an Array of exception classes) that
are referenced from the request path (params_readable? -> rescue * RESCUABLE_MIME_FORMAT_ERRORS). These Arrays are non-frozen, hence
non-shareable, so a worker Ractor raises Ractor::IsolationError when it
reads them. Freeze the mutable constant-containing modules so workers can
read shareable copies. Regexp/Class constants are already shareable; only
the wrapping Array/Hash need freezing.
778 779 780 781 782 783 784 785 786 787 788 789 790 |
# File 'lib/ractor_rails_shim/patches/action_dispatch.rb', line 778 def _freeze_mime_negotiation! return unless defined?(::ActionDispatch::Http::MimeNegotiation) mod = ::ActionDispatch::Http::MimeNegotiation mod.constants.each do |name| c = mod.const_get(name) rescue nil next unless c.is_a?(::Array) || c.is_a?(::Hash) next if c.frozen? c.freeze _swallow("make mime negotiation constant shareable") { ::Ractor.make_shareable(c) } end rescue StandardError => e warn "[ractor-rails-shim] _freeze_mime_negotiation!: #{e.class}: #{e.}" end |
._freeze_secure_random_alphabets! ⇒ Object
Freeze SecureRandom::BASE36_ALPHABET / BASE58_ALPHABET if they exist
and are not yet shareable. Idempotent — safe to call from install, from
prepare_for_ractors!, and from the TracePoint callback.
320 321 322 323 324 325 326 327 328 329 330 |
# File 'lib/ractor_rails_shim/patches/active_storage.rb', line 320 def _freeze_secure_random_alphabets! return unless defined?(::SecureRandom) %i[BASE36_ALPHABET BASE58_ALPHABET].each do |const| next unless ::SecureRandom.const_defined?(const) alphabet = ::SecureRandom.const_get(const) next if ::Ractor.shareable?(alphabet) ::SecureRandom.const_set(const, ::Ractor.make_shareable(alphabet)) rescue StandardError nil end end |
._install_abstract_controller_patch ⇒ Object
Patch AbstractController::Base.controller_path to not write/read the action_methods, clear_action_methods!, abstract!, abstract?, and _prefixes to route through IES or use the shareable fallback.
95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 |
# File 'lib/ractor_rails_shim/patches/action_controller.rb', line 95 def _install_abstract_controller_patch return if @abstract_controller_patched @abstract_controller_patched = true _register_patch :abstract_controller, "8.1" return unless defined?(::AbstractController::Base) ac = ::AbstractController::Base # Populate the shareable abstract registry from every loaded controller # class's @abstract ivar (set by abstract! / inherited at boot). Workers # read this via the patched abstract? (per-class values can't live in # per-Ractor IES). registry = {} ac.descendants.each do |klass| begin registry[klass] = klass.instance_variable_get(:@abstract) if klass.instance_variable_defined?(:@abstract) rescue StandardError => e # ignore — best-effort end end registry[ac] = ac.instance_variable_get(:@abstract) if ac.instance_variable_defined?(:@abstract) registry.freeze Ractor.make_shareable(registry) self._abstract_registry = registry ac.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def controller_path cache = (RactorRailsShim.storage[:ractor_rails_shim_controller_path_cache] ||= {}) v = cache[self] return v if v if Ractor.main? && instance_variable_defined?(:@controller_path) v = @controller_path cache[self] = v return v end computed = anonymous? ? nil : name.delete_suffix("Controller").underscore cache[self] = computed computed end # action_methods: `@action_methods ||= public_instance_methods(true) - # internal_methods).map(&:name).to_set` — raw class-ivar lazy init. # The value is a Set of Symbols (shareable once frozen). Route through # IES; workers compute it from public_instance_methods (no ivar read) # and cache in their own slot. Read per-request during dispatch. def action_methods cache = (RactorRailsShim.storage[:ractor_rails_shim_action_methods_cache] ||= {}) v = cache[self] return v if v if Ractor.main? && instance_variable_defined?(:@action_methods) v = @action_methods cache[self] = v return v end methods = public_instance_methods(true) - internal_methods methods.map!(&:name) computed = methods.to_set cache[self] = computed computed end def clear_action_methods! if Ractor.main? @action_methods = nil end RactorRailsShim.storage[:ractor_rails_shim_action_methods_cache] = nil end # abstract! / abstract / abstract? — raw class ivar (@abstract), a # per-CLASS boolean. IES is per-Ractor (single value), so we can't use a # single IES key for all classes. Instead use a shareable registry # (Hash class→bool) built at prepare_for_ractors! time. Workers read # the registry; main reads its live @abstract ivar (set by abstract! # / inherited). `internal_methods` loops on abstract?. # # The registry is frozen with `Ractor.make_shareable` at install time so # it can travel the shared app graph. Mutating a frozen Hash raises # FrozenError, so `abstract!` only writes the live ivar (main) and # guards the registry write behind a mutability check. `abstract!` # after install in main is a no-op on the registry (already captured); # callers that need to flip a class to abstract post-install should # rebuild the registry (rare — abstract! is a boot-time declaration). def abstract! reg = RactorRailsShim._abstract_registry reg[self] = true if reg && !reg.frozen? && Ractor.main? @abstract = true if Ractor.main? end def abstract if Ractor.main? && instance_variable_defined?(:@abstract) @abstract else (RactorRailsShim._abstract_registry || RactorRailsShim::ABSTRACT_REGISTRY)[self] || false end end alias_method :abstract?, :abstract RUBY # Patch ActionView::ViewPaths::ClassMethods#_prefixes (overrides any # Base version). Original: `@_prefixes ||= begin; return local_prefixes # if superclass.abstract?; local_prefixes + superclass._prefixes; end`. # @_prefixes is a per-CLASS class ivar (workers can't read). Recurse # using the patched abstract? and cache in a per-Ractor Hash by class. if defined?(::ActionView::ViewPaths::ClassMethods) vp = ::ActionView::ViewPaths::ClassMethods vp.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def _prefixes cache = (RactorRailsShim.storage[:ractor_rails_shim_vp_prefixes_cache] ||= {}) v = cache[self] return v if v if Ractor.main? && instance_variable_defined?(:@_prefixes) v = @_prefixes cache[self] = v return v end computed = if superclass.respond_to?(:abstract?) && superclass.abstract? local_prefixes elsif superclass.respond_to?(:_prefixes) local_prefixes + superclass._prefixes else local_prefixes end cache[self] = computed computed end RUBY end # AbstractController::UrlFor::ClassMethods#action_methods ALSO has a # `@action_methods ||= ...` lazy init (it overrides Base.action_methods # to subtract route helper names). Patch it the same way. if defined?(::AbstractController::UrlFor::ClassMethods) url_for_cm = ::AbstractController::UrlFor::ClassMethods url_for_cm.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def action_methods cache = (RactorRailsShim.storage[:ractor_rails_shim_url_for_action_methods_cache] ||= {}) v = cache[self] return v if v if Ractor.main? && instance_variable_defined?(:@action_methods) v = @action_methods cache[self] = v return v end # NOTE: the original reads `@action_methods ||= if _routes; super - # _routes.named_routes.helper_names; else; super; end`. But # `_routes` is a singleton method defined via `define_method` with # a block (route_set.rb:610), capturing the defining Ractor's # binding → "defined with an un-shareable Proc in a different # Ractor" when called from a worker. Instead, read the route set # directly from the shareable Rails.application (frozen, shared). base = super routes = Ractor.main? ? (respond_to?(:_routes) ? _routes : nil) : (defined?(::Rails) && ::Rails.application ? ::Rails.application.routes : nil) computed = if routes base - routes.named_routes.helper_names else base end cache[self] = computed computed end RUBY end end |
._install_action_controller_controller_name_patch ⇒ Object
Patch ActionController::Metal.controller_name (a class method). It
memoizes its computed String in a lazy class ivar (@controller_name ||=),
which a worker Ractor cannot write. Route the cache through
IsolatedExecutionState keyed by the class name so each Ractor builds its
own copy; the computation is deterministic from the class name.
262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 |
# File 'lib/ractor_rails_shim/patches/action_controller.rb', line 262 def _install_action_controller_controller_name_patch return if @action_controller_controller_name_patched @action_controller_controller_name_patched = true _register_patch :action_controller_controller_name, "8.1" return unless defined?(::ActionController::Metal) ::ActionController::Metal.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def controller_name key = :"ractor_rails_shim_controller_name_\#{name}" v = RactorRailsShim.storage[key] return v if v cn = (name.demodulize.delete_suffix("Controller").underscore unless anonymous?) RactorRailsShim.storage[key] = cn cn end RUBY end |
._install_action_controller_forgery_patch ⇒ Object
Replay ActionController's forgery-protection flag in worker Ractors.
allow_forgery_protection delegates to config.allow_forgery_protection;
in a worker the shared config is an empty ActiveSupport::OrderedOptions,
so forms never render a CSRF token (breaking token issuance/validation in
workers). Capture the resolved flag from the main Ractor at prepare time
(after any boot-time override) and force it in workers so token
issuance/validation work off the frozen, shared graph.
61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 |
# File 'lib/ractor_rails_shim/patches/action_controller.rb', line 61 def _install_action_controller_forgery_patch return if @action_controller_forgery_patched @action_controller_forgery_patched = true _register_patch :action_controller_forgery, "8.1" return unless defined?(::ActionController::RequestForgeryProtection) return unless defined?(::ActionController::Base) # Capture the resolved flag. ActionController::Base.allow_forgery_protection # is a class method that reads config; in the main Ractor config carries # the boot-time override. Booleans are shareable. _reassign_shareable_const( :SHAREABLE_ALLOW_FORGERY, !!::ActionController::Base.allow_forgery_protection ) mod = ::ActionController::Base # Override the instance-method delegation (used by protect_against_forgery?). mod.module_eval do def allow_forgery_protection ::Ractor.main? ? super : ::RactorRailsShim::SHAREABLE_ALLOW_FORGERY end end # Override the class-method delegation (singleton delegate to :config). mod.singleton_class.module_eval do def allow_forgery_protection ::Ractor.main? ? super : ::RactorRailsShim::SHAREABLE_ALLOW_FORGERY end end end |
._install_action_dispatch_http_url_patch ⇒ Object
ActionDispatch::Http::URL reads the tld_length class variable
DIRECTLY (@@tld_length) in normalize_host and in the default-parameter
of domain/subdomains/subdomain. Class variables are not readable from
a non-main Ractor, so a worker raises
"Ractor::IsolationError: can not access class variables ... @@tld_length".
The shim routes the mattr_accessor :tld_length READER through IES, but the
literal @@tld_length references bypass that reader. Replace them with the
accessor method (which the shim's mattr_accessor rewrite makes
worker-safe). domain/subdomains/subdomain live in the Url module
mixed into ActionDispatch::Request, so patch that module too.
911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 |
# File 'lib/ractor_rails_shim/patches/action_dispatch.rb', line 911 def _install_action_dispatch_http_url_patch return if @action_dispatch_http_url_patched @action_dispatch_http_url_patched = true _register_patch :action_dispatch_http_url, "8.1" return unless defined?(::ActionDispatch::Http::URL) url = ::ActionDispatch::Http::URL # normalize_host is a module_function: build_host_url calls the # MODULE-LEVEL copy, so redefining the instance method alone leaves the # original (@@tld_length-reading) one in place. Patch the singleton # (module-level) method instead. url.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def normalize_host(_host, options) return _host unless named_host?(_host) tld_length = options[:tld_length] || tld_length() subdomain = options.fetch :subdomain, true domain = options[:domain] host = +"" if subdomain == true return _host if domain.nil? host << extract_subdomains_from(_host, tld_length).join(".") elsif subdomain host << subdomain.to_param end host << "." unless host.empty? host << (domain || extract_domain_from(_host, tld_length)) host end RUBY if defined?(::ActionDispatch::Http::URL::Url) ::ActionDispatch::Http::URL::Url.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def domain(tld_length = tld_length()) ActionDispatch::Http::URL.extract_domain(host, tld_length) end def subdomains(tld_length = tld_length()) ActionDispatch::Http::URL.extract_subdomains(host, tld_length) end def subdomain(tld_length = tld_length()) ActionDispatch::Http::URL.extract_subdomain(host, tld_length) end RUBY end end |
._install_action_dispatch_mounted_helpers_patch ⇒ Object
Patch ActionDispatch::Routing::RouteSet::MountedHelpers#main_app (and
its _main_app worker). main_app is define_method-ed at boot capturing
the MAIN ractor's RouteSet + url_helpers in its block binding, so
calling it from a worker Ractor raises "defined with an un-shareable
Proc in a different Ractor". Devise's _devise_route_context calls
send(:main_app) to get the route context for its url helpers. Redefine
via string eval, building the RoutesProxy from the shareable RouteSet
(RactorRailsShim::SHAREABLE_ROUTES) so workers get a valid context.
183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 |
# File 'lib/ractor_rails_shim/patches/action_dispatch.rb', line 183 def _install_action_dispatch_mounted_helpers_patch return if @mounted_helpers_patched @mounted_helpers_patched = true _register_patch :mounted_helpers, "8.1" return unless defined?(::ActionDispatch::Routing::RouteSet::MountedHelpers) mh = ::ActionDispatch::Routing::RouteSet::MountedHelpers return unless mh.method_defined?(:main_app) mh.class_eval <<-RUBY, __FILE__, __LINE__ + 1 def _main_app ::ActionDispatch::Routing::RoutesProxy.new( RactorRailsShim::SHAREABLE_ROUTES, _routes_context, RactorRailsShim::SHAREABLE_ROUTES.url_helpers, nil ) end def main_app @_main_app ||= _main_app end RUBY end |
._install_action_dispatch_routing_patch ⇒ Object
Patch ActionDispatch::Routing::RouteSet URL generation. The named route
helpers (post_path, session_path, ...) are generated at boot in the
main Ractor by RouteSet#add, which captures the PATH / UNKNOWN
lambda constants (route_set.rb:349-350) into each helper's url_strategy
ivar. Those lambdas were defined in the main Ractor, so calling them from
a worker Ractor raises RuntimeError: defined with an un-shareable Proc in a different Ractor. Replace them with shareable Callable objects (Plain
old objects with a #call method, made shareable via
Ractor.make_shareable) that delegate to ActionDispatch::Http::URL
(module methods, callable from any Ractor).
215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 |
# File 'lib/ractor_rails_shim/patches/action_dispatch.rb', line 215 def _install_action_dispatch_routing_patch return if @action_dispatch_routing_patched @action_dispatch_routing_patched = true _register_patch :action_dispatch_routing, "8.1" return unless defined?(::ActionDispatch::Routing::RouteSet) return unless defined?(::ActionDispatch::Http::URL) # Shareable Callable replacements for the PATH / UNKNOWN lambda constants. unless RactorRailsShim.const_defined?(:AVPathStrategy) RactorRailsShim.const_set(:AVPathStrategy, Ractor.make_shareable(Object.new.tap do |o| def o.call() ActionDispatch::Http::URL.path_for() end end)) end unless RactorRailsShim.const_defined?(:AVUnknownStrategy) RactorRailsShim.const_set(:AVUnknownStrategy, Ractor.make_shareable(Object.new.tap do |o| def o.call() ActionDispatch::Http::URL.url_for() end end)) end rs = ::ActionDispatch::Routing::RouteSet # `RouteSet::PATH` / `RouteSet::UNKNOWN` (route_set.rb:349-350) are lambdas # defined in the main Ractor. They are referenced as default parameter # values (`def url_for(..., url_strategy = UNKNOWN, ...)`) and inside # `path_for`/`define_url_helper`. Reading those constants from a worker # Ractor raises `Ractor::IsolationError: can not access non-shareable # objects in constant ...UNKNOWN`. Replace them with the shareable # Callable objects (which perform the identical `ActionDispatch::Http::URL` # lookups) so workers read a shareable constant instead of an unshareable # lambda. Behaviour is unchanged in main (same `#call(options)` contract). unless rs.const_defined?(:PATH) && Ractor.shareable?(rs.const_get(:PATH)) verbose = $VERBOSE $VERBOSE = nil rs.const_set(:PATH, RactorRailsShim::AVPathStrategy) rs.const_set(:UNKNOWN, RactorRailsShim::AVUnknownStrategy) $VERBOSE = verbose if defined?(verbose) end # `ActionDispatch::Journey::Router::Utils::ENCODER` (`UriEncoder.new`) and # its sibling constants (`DEC2HEX`, `EMPTY`, `US_ASCII`, the unreserved/ # segment regexes, ...) are referenced by `escape_path`/`escape_segment`, # which the journey URL formatter invokes while building a path in a worker # Ractor. An unfrozen object/array/string held in a constant is unshareable, # so workers reading it raise IsolationError. Freeze each constant in place # (via `Ractor.make_shareable`) so the shareable-frozen values are readable # from any Ractor. if defined?(::ActionDispatch::Journey::Router::Utils) utu = ::ActionDispatch::Journey::Router::Utils utu.constants.each do |c| begin v = utu.const_get(c) Ractor.make_shareable(v) if v && !Ractor.shareable?(v) rescue StandardError nil end end end # `RouteSet::RESERVED_OPTIONS` (route_set.rb:838) is a mutable Array used # as a default parameter value in `url_for`/`path_for`. A non-frozen Array # is unshareable, so workers reading the constant raise IsolationError. # Freeze it in place so the constant becomes shareable. begin Ractor.make_shareable(rs.const_get(:RESERVED_OPTIONS)) rescue StandardError nil end # Warm the lazy (memoized) caches on every Journey route and its # Path::Pattern BEFORE `make_app_shareable!` deep-freezes them. Several of # these caches are filled with `||=` (e.g. `Route#parts`, # `Route#required_parts`, `Route#required_defaults`, # `Path::Pattern#requirements_for_missing_keys_check`, `#to_regexp`, # `#offsets`, `#required_names`, `#optional_names`). They are computed # deterministically, but assigning the memoized ivar on a frozen object # from a worker Ractor raises FrozenError. Computing them here (in main, # while the objects are still mutable) populates the ivars so the frozen, # shared copies already hold the values and workers only read them. if Ractor.main? && defined?(::Rails) && ::Rails.application _swallow("warm journey routes") do rset = ::Rails.application.routes all = [] all.concat(rset.named_routes.send(:routes).values) rescue nil all.concat(rset.set.routes) rescue nil all.uniq.each do |route| next unless route.respond_to?(:path) route.parts rescue nil route.required_parts rescue nil route.required_defaults rescue nil p = route.path p.requirements_for_missing_keys_check rescue nil p.to_regexp rescue nil p.offsets rescue nil p.required_names rescue nil p.optional_names rescue nil end end end # Capture the (shareable) RouteSet so workers can build URLs without # calling `#_routes` — which is `define_method(:_routes) { @_routes || # routes }` (route_set.rb:612), a block capturing the main Ractor's # `routes` reference. Calling that block from a worker raises # "defined with an un-shareable Proc in a different Ractor". We stash the # RouteSet as a shareable constant and point `_routes` at it. if Ractor.main? begin routes = Rails.application.routes if defined?(::Rails) && ::Rails.application unless routes.nil? verbose = $VERBOSE $VERBOSE = nil RactorRailsShim.const_set(:SHAREABLE_ROUTES, routes) unless RactorRailsShim.const_defined?(:SHAREABLE_ROUTES) end rescue StandardError nil ensure $VERBOSE = verbose if defined?(verbose) end end # RouteSet#url_for receives url_strategy (the captured PATH/UNKNOWN # lambda) and calls `url_strategy.call options` internally. Coerce a # non-shareable strategy to the shareable Callable before delegating. unless rs.method_defined?(:url_for_without_shim) rs.alias_method(:url_for_without_shim, :url_for) end rs.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def url_for(options, route_name = nil, url_strategy = UNKNOWN, method_name = nil, reserved = RESERVED_OPTIONS) url_strategy = RactorRailsShim::AVUnknownStrategy unless Ractor.shareable?(url_strategy) url_for_without_shim(options, route_name, url_strategy, method_name, reserved) end RUBY # OptimizedUrlHelper#call invokes `url_strategy.call options` DIRECTLY # (route_set.rb:228) without going through url_for, so the coercion above # doesn't cover it. Redefine it to call the shareable strategy Callable # (the one passed in by our redefined helper methods), replicating the # original body exactly otherwise. ::ActionDispatch::Routing::RouteSet::NamedRouteCollection::UrlHelper::OptimizedUrlHelper.class_eval <<-RUBY, __FILE__, __LINE__ + 1 def call(t, method_name, args, inner_options, url_strategy) if args.size == arg_size && !inner_options && optimize_routes_generation?(t) options = t.url_options.merge @options path = optimized_helper(args) path << "/" if options[:trailing_slash] && !path.end_with?("/") options[:path] = path original_script_name = options.delete(:original_script_name) script_name = t._routes.find_script_name(options) if original_script_name script_name = original_script_name + script_name end options[:script_name] = script_name strat = Ractor.shareable?(url_strategy) ? url_strategy : RactorRailsShim::AVPathStrategy strat.call(options) else super end end RUBY # The base (non-optimized) `UrlHelper#call` (route_set.rb:278) is hit # whenever a helper is generated as a plain `UrlHelper` (e.g. our re-run # loop) or when optimization is skipped. The original reads `t.url_options` # and `t._routes`, both of which assume `t` is a controller/view context # whose `_routes`/`url_options` are reachable from a worker. In practice # `t` may be the `NamedRouteCollection` (helpers proxy) or any object that # lacks these. Route both through the shareable RouteSet / url-options # snapshot, falling back to `t`'s own accessors only when it actually # provides them (real controller/view). This makes path generation # (host-independent) work from any Ractor regardless of `t`. ::ActionDispatch::Routing::RouteSet::NamedRouteCollection::UrlHelper.class_eval <<-RUBY, __FILE__, __LINE__ + 1 def call(t, method_name, args, inner_options, url_strategy) begin controller_options = t.url_options rescue StandardError controller_options = RactorRailsShim::URL_OPTIONS_DEFAULTS || {} end options = controller_options.merge @options hash = handle_positional_args(controller_options, inner_options || {}, args, options, @segment_keys) begin routes = t._routes rescue StandardError routes = RactorRailsShim::SHAREABLE_ROUTES end routes.url_for(hash, route_name, url_strategy, method_name) end RUBY # Named route helpers (`post_path`, `post_url`, ...) are generated by # `NamedRouteCollection#define_url_helper` (route_set.rb:333) via # `mod.define_method(name) { |*args| ... helper.call(...) }` — a BLOCK that # captures the `helper` object (an OptimizedUrlHelper holding the route) # and the `url_strategy` lambda (PATH/UNKNOWN, both defined in main). # Calling that block from a worker Ractor raises # "defined with an un-shareable Proc in a different Ractor" before any # code runs. Patch `define_url_helper` to (a) make the helper shareable # via `Ractor.make_shareable` (deep-freeze; routes are read-only after # boot) and stash it in a shareable Hash keyed by name, and (b) define the # method with a STRING (no captured binding) that references the Hash and # the shareable strategy Callable directly. unless RactorRailsShim.const_defined?(:URL_HELPERS) RactorRailsShim.const_set(:URL_HELPERS, {}) end nrc = ::ActionDispatch::Routing::RouteSet::NamedRouteCollection unless nrc.method_defined?(:define_url_helper_without_shim) nrc.alias_method(:define_url_helper_without_shim, :define_url_helper) end nrc.define_method(:define_url_helper) do |mod, name, helper, url_strategy| # Detach the helper from the live route object before deep-freezing # it for cross-Ractor sharing. The non-optimized UrlHelper#call only # needs @options / @segment_keys / @route_name to build the options # hash and then delegates to `t._routes.url_for(route_name, ...)`, # which looks the route up in the (shareable) RouteSet by name. The # @route reference would pull the whole route graph into the freeze, # freezing objects that make_app_shareable! must still be able to # mutate (e.g. Devise route constraints) -> FrozenError. RactorRailsShim::Funnel.swallow("freeze url helper") do if helper.respond_to?(:instance_variable_get) helper.instance_variable_set(:@route, nil) rescue nil opts = helper.instance_variable_get(:@options) helper.instance_variable_set(:@options, opts.dup.freeze) rescue nil segs = helper.instance_variable_get(:@segment_keys) helper.instance_variable_set(:@segment_keys, segs.dup.freeze) rescue nil end helper = Ractor.make_shareable(helper) end RactorRailsShim::URL_HELPERS[name] = helper strategy_const = url_strategy.equal?(::ActionDispatch::Routing::RouteSet::PATH) ? "AVPathStrategy" : "AVUnknownStrategy" mod.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def #{name}(*args) last = args.last options = \\ case last when ::Hash args.pop when ::ActionController::Parameters args.pop.to_h end RactorRailsShim::URL_HELPERS[#{name.inspect}].call( self, #{name.inspect}, args, options, RactorRailsShim::#{strategy_const}) end RUBY end # Re-run the (now patched) helper generation for every route already # drawn at boot, so the helpers the app actually uses are worker-safe. # Routes are drawn during `Rails.application.initialize!`, which runs # before `prepare_for_ractors!`, so the originals are still block-based. if Ractor.main? && defined?(::Rails) && ::Rails.application begin named = ::Rails.application.routes.named_routes path_mod = named.instance_variable_get(:@path_helpers_module) url_mod = named.instance_variable_get(:@url_helpers_module) named.send(:routes).each do |route_name, route| # Build the helper directly via `UrlHelper.new` (NOT `UrlHelper.create`, # which calls `optimize_helper?` -> `route.glob?` -> `route.path.ast.glob?` # and `route.path.ast` is nil by the time routes are finalized post-boot). helper = ::ActionDispatch::Routing::RouteSet::NamedRouteCollection::UrlHelper.new( route, route.defaults, route_name) named.define_url_helper(path_mod, :"#{route_name}_path", helper, ::ActionDispatch::Routing::RouteSet::PATH) if path_mod named.define_url_helper(url_mod, :"#{route_name}_url", helper, ::ActionDispatch::Routing::RouteSet::UNKNOWN) if url_mod end verbose = $VERBOSE $VERBOSE = nil RactorRailsShim.const_set(:URL_HELPERS, Ractor.make_shareable(RactorRailsShim::URL_HELPERS)) unless Ractor.shareable?(RactorRailsShim::URL_HELPERS) rescue StandardError nil ensure $VERBOSE = verbose if defined?(verbose) end end # `ActionController::UrlFor#url_options` (action_controller/metal/url_for.rb:45) # builds its option hash from `request.host` / `request.optional_port` / # `request.protocol` / `request.path_parameters` and merges in # `default_url_options`. The controller instance rendered in a worker Ractor # has a `request` built from the shared Rack env, but the values it returns # (and the `default_url_options` class value, an unshareable Hash stored as a # class ivar on ActionController::Base) cannot be read/called from a worker # without raising Ractor isolation errors. For path-only helpers (the common # case in views) the host/port/protocol are irrelevant, and `default_url_options` # is the same deterministic value everywhere, so capture it once in main as a # shareable snapshot and have workers use it directly, skipping the # request-derived portion. unless RactorRailsShim.const_defined?(:URL_OPTIONS_DEFAULTS) begin if Ractor.main? && defined?(::ActionController::Base) defaults = ::ActionController::Base. defaults = defaults.dup.freeze if defaults.respond_to?(:freeze) RactorRailsShim.const_set(:URL_OPTIONS_DEFAULTS, Ractor.make_shareable(defaults)) end rescue StandardError nil end end if defined?(::ActionController::UrlFor) ::ActionController::UrlFor.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def url_options return super if Ractor.main? @_url_options ||= begin opts = (RactorRailsShim::URL_OPTIONS_DEFAULTS || {}).dup begin req = request if respond_to?(:request) if req opts[:host] = req.host if opts[:host].nil? && req.respond_to?(:host) opts[:protocol] = req.protocol if opts[:protocol].nil? && req.respond_to?(:protocol) opts[:port] = req.port if opts[:port].nil? && req.respond_to?(:port) opts[:_recall] = req.path_parameters if req.respond_to?(:path_parameters) end rescue StandardError nil end opts.freeze end end RUBY end # NOTE: the block-based `_routes` accessors that break workers are now # fixed at their source by `_install_url_helpers_patch` (patches/ # url_helpers.rb), which intercepts `Module#redefine_singleton_method` # / `Module#define_method` for `:_routes` and replaces the main-Ractor # block with a string-eval'd method returning `Rails.application.routes`. # No per-class enumeration needed. end |
._install_action_view_field_type_patch ⇒ Object
Patch ActionView::Helpers::Tags::TextField.field_type (and the subclasses
EmailField/PasswordField/... that inherit it). The original memoizes its
computed String in a lazy class ivar (@field_type ||= name...). The
class ivar is per-subclass and unshareable-writable from a worker Ractor.
Route the cache through IsolatedExecutionState keyed by the class name so
each Ractor builds its own copy; the computation is deterministic.
645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 |
# File 'lib/ractor_rails_shim/patches/action_view.rb', line 645 def _install_action_view_field_type_patch return if @action_view_field_type_patched @action_view_field_type_patched = true _register_patch :action_view_field_type, "8.1" return unless defined?(::ActionView::Helpers::Tags::TextField) tf = ::ActionView::Helpers::Tags::TextField tf.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def field_type key = :"ractor_rails_shim_field_type_\#{name}" v = RactorRailsShim.storage[key] return v if v ft = name.split("::").last.sub("Field", "").downcase RactorRailsShim.storage[key] = ft ft end RUBY end |
._install_action_view_partial_path_patch ⇒ Object
Patch ActionView::AbstractRenderer::ObjectRendering#partial_path. The
original reads PREFIXED_PARTIAL_NAMES — a Concurrent::Map constant
(nested Concurrent::Maps) — and writes a nested entry via
PREFIXED_PARTIAL_NAMES[@context_prefix][path] ||= .... Concurrent::Map
is intrinsically unshareable (it refuses #freeze), so a worker Ractor
cannot read the constant NOR write to it. Redefine the method to use a
per-Ractor Hash via IsolatedExecutionState (each Ractor builds its own
cache from merge_prefix_into_object_path, which is deterministic).
615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 |
# File 'lib/ractor_rails_shim/patches/action_view.rb', line 615 def _install_action_view_partial_path_patch return if @action_view_partial_path_patched @action_view_partial_path_patched = true _register_patch :action_view_partial_path, "8.1" return unless defined?(::ActionView::AbstractRenderer) ::ActionView::AbstractRenderer::ObjectRendering.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def partial_path(object, view) object = object.to_model if object.respond_to?(:to_model) path = if object.respond_to?(:to_partial_path) object.to_partial_path else raise ArgumentError.new("\#{object.inspect}' is not an ActiveModel-compatible object. It must implement #to_partial_path.") end if view.prefix_partial_path_with_controller_namespace cache = (RactorRailsShim.storage[:ractor_rails_shim_prefixed_partial_names] ||= {}) cache[@context_prefix] ||= {} cache[@context_prefix][path] ||= merge_prefix_into_object_path(@context_prefix, path.dup) else path end end RUBY end |
._install_action_view_resolver_patch ⇒ Object
Patch ActionView::FileSystemResolver#_find_all. The original reads the
resolver's @unbound_templates cache, which make_app_shareable!
rewrites from a Concurrent::Map into a frozen Hash (Concurrent::Map
refuses #freeze). The original then calls cache.compute_if_absent
(a Concurrent::Map API) on it, which a frozen Hash lacks -> NoMethodError
in a worker Ractor. Route the per-virtual-path cache through
IsolatedExecutionState instead: each Ractor builds its own mutable Hash
(deterministic from disk via unbound_templates_from_path), so the
frozen shareable app graph is never mutated.
497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 |
# File 'lib/ractor_rails_shim/patches/action_view.rb', line 497 def _install_action_view_resolver_patch return if @action_view_resolver_patched @action_view_resolver_patched = true _register_patch :action_view_resolver, "8.1" return unless defined?(::ActionView::FileSystemResolver) # @unbound_templates is a Concurrent::Map (resolver.rb). Ractor.make_shareable! # cannot freeze a Concurrent::Map ("undefined method 'freeze' for an # instance of Concurrent::Map"), so the entire view_paths PathSet fails to # become shareable and workers fall back to an EMPTY view-path Hash -> every # request renders "No template found". The per-virtual-path lookup cache is # already routed through IsolatedExecutionState by the _find_all patch # below, so @unbound_templates is never read at request time. Replace it # with a plain (freezable) Hash so make_shareable! can deep-freeze each # resolver and the PathSet becomes shareable across Ractors. ::ActionView::FileSystemResolver.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def initialize(path) raise ArgumentError, "path already is a Resolver class" if path.is_a?(::ActionView::Resolver) @unbound_templates = {} @path_parser = ::ActionView::Resolver::PathParser.new @path = ::File.expand_path(path) super() end RUBY if Ractor.main? # The resolvers already created during boot live in the PathRegistry's # @view_paths_by_class / @file_system_resolvers class ivars (keyed by # class name). They were built with the original initialize, so their # @unbound_templates is a Concurrent::Map. Convert every such resolver # to a plain (freezable) Hash so make_app_shareable! can deep-freeze the # PathSet and workers get a non-empty view-path fallback. resolvers = [] if ::ActionView::PathRegistry.instance_variable_defined?(:@view_paths_by_class) ::ActionView::PathRegistry.instance_variable_get(:@view_paths_by_class).each_value do |ps| resolvers.concat(ps.to_a) if ps.respond_to?(:to_a) end end if ::ActionView::PathRegistry.instance_variable_defined?(:@file_system_resolvers) ::ActionView::PathRegistry.instance_variable_get(:@file_system_resolvers).each_value do |v| resolvers.concat(v.to_a) if v.respond_to?(:to_a) end end converted = 0 resolvers.uniq.each do |r| if r.instance_variable_defined?(:@unbound_templates) && r.instance_variable_get(:@unbound_templates).is_a?(::Concurrent::Map) r.instance_variable_set(:@unbound_templates, {}) converted += 1 end end end # Eager-load nested constants referenced below (workers can't autoload). if Ractor.main? ::ActionView::TemplateDetails rescue nil ::ActionView::TemplatePath rescue nil end ::ActionView::FileSystemResolver.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def _find_all(name, prefix, partial, details, key, locals) requested_details = key || ::ActionView::TemplateDetails::Requested.new(**details) virtual = ::ActionView::TemplatePath.virtual(name, prefix, partial) # Key the cache by resolver path AND virtual path: each resolver # (app/views, each gem) has its own @path and its own templates. # Keying only by virtual path would let the first resolver poison # the cache for all others (e.g. app/views caches [] for # devise/sessions/new, hiding the template that lives in the # devise gem resolver). cache = (RactorRailsShim.storage[:ractor_rails_shim_resolver_cache] ||= {}) cache_key = [@path, virtual] unbound_templates = if cache.key?(cache_key) cache[cache_key] else path = ::ActionView::TemplatePath.build(name, prefix, partial) tmpls = unbound_templates_from_path(path) cache[cache_key] = tmpls tmpls end filter_and_sort_by_details(unbound_templates, requested_details).map do |unbound_template| unbound_template.bind_locals(locals) end end RUBY # Patch ActionView::Resolver::PathParser#parse. The resolver's # @path_parser instance is part of the shareable app graph frozen by # make_app_shareable!, and the original method memoizes its compiled # regex in `@regex ||= build_path_regex` — assigning @regex on a frozen # object raises FrozenError in a worker Ractor. Route the memoization # through IsolatedExecutionState keyed by the parser's object_id, so each # Ractor compiles its own regex once without mutating the frozen object. if defined?(::ActionView::Resolver::PathParser) pp = ::ActionView::Resolver::PathParser pp_key = :ractor_rails_shim_path_parser_regex pp_key_str = pp_key.inspect pp.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def parse(path) regex = RactorRailsShim.storage[:"#{pp_key_str}_\#{object_id}"] ||= build_path_regex match = regex.match(path) path = ::ActionView::TemplatePath.build(match[:action], match[:prefix] || "", !!match[:partial]) details = ::ActionView::TemplateDetails.new( match[:locale]&.to_sym, match[:handler]&.to_sym, match[:format]&.to_sym, match[:variant]&.to_sym ) ::ActionView::Resolver::PathParser::ParsedPath.new(path, details) end RUBY end end |
._install_action_view_safe_join_patch ⇒ Object
Patch ActionView::Helpers::OutputSafetyHelper#safe_join. Its default
separator parameter is sep = $, — a reference to the $ global, which a
worker Ractor cannot read (Ractor::IsolationError: can not access global
variable $,). The $ global is nil in every normal Rails process, so
defaulting to nil reproduces the identical behaviour without touching the
global.
669 670 671 672 673 674 675 676 677 678 679 680 |
# File 'lib/ractor_rails_shim/patches/action_view.rb', line 669 def _install_action_view_safe_join_patch return if @action_view_safe_join_patched @action_view_safe_join_patched = true _register_patch :action_view_safe_join, "8.1" return unless defined?(::ActionView::Helpers::OutputSafetyHelper) ::ActionView::Helpers::OutputSafetyHelper.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def safe_join(array, sep = nil) sep = ERB::Util.unwrapped_html_escape(sep) array.flatten.map! { |i| ERB::Util.unwrapped_html_escape(i) }.join(sep).html_safe end RUBY end |
._install_action_view_sanitize_patch ⇒ Object
ActionView::Helpers::SanitizeHelper::ClassMethods memoizes its sanitizer
instances as class ivars on the (shared, frozen) module
(@safe_list_sanitizer / @full_sanitizer / @link_sanitizer). A non-main
worker Ractor cannot set an ivar on a class/module defined in the main
Ractor, so sanitize / simple_format raise
"can not set instance variables of classes/modules by non-main Ractors".
Route the memoization through a per-worker cache (Ractor.current) and
build the sanitizer lazily inside the worker — the value never has to
cross the Ractor boundary, so no shareability gymnastics are needed.
691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 |
# File 'lib/ractor_rails_shim/patches/action_view.rb', line 691 def _install_action_view_sanitize_patch return if @action_view_sanitize_patched @action_view_sanitize_patched = true _register_patch :action_view_sanitize, "8.1" return unless defined?(::ActionView::Helpers::SanitizeHelper::ClassMethods) mod = ::ActionView::Helpers::SanitizeHelper::ClassMethods mod.module_eval do def safe_list_sanitizer Ractor.current[:__rrs_safe_list_sanitizer__] ||= sanitizer_vendor.safe_list_sanitizer.new end def full_sanitizer Ractor.current[:__rrs_full_sanitizer__] ||= sanitizer_vendor.full_sanitizer.new end def link_sanitizer Ractor.current[:__rrs_link_sanitizer__] ||= sanitizer_vendor.link_sanitizer.new end end end |
._install_actionmailer_mailer_name_patch ⇒ Object
--- ActionMailer::Base.mailer_name (raw class ivar on the mailer subclass) ---
mailer_name (aliased to controller_path) computes the mailer's view path
prefix from its class name, but reads the lazy @mailer_name class ivar —
unreadable from a worker Ractor ("can not get unshareable values from
instance variables of classes/modules from non-main Ractors"). The value
defaults to name.underscore, derivable from the (shareable) class name.
Redefining mailer_name/controller_path directly does NOT help: every
internal caller captures the original method object through a stale
cross-Ractor inline cache, and controller_path is an alias bound to that
original method object. Instead we override the three internal callers
(each a small, stable method) via string-eval def, so the methods the
worker actually invokes derive the prefix from the shareable class name
directly — never touching @mailer_name.
421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 |
# File 'lib/ractor_rails_shim/patches/mail.rb', line 421 def self._install_actionmailer_mailer_name_patch return unless defined?(::ActionMailer::Base) return if @am_mailer_name_patched @am_mailer_name_patched = true # `ActionView::ViewPaths::ClassMethods#local_prefixes` (used by the mailer # view-path lookup) returns `[controller_path]`, and `controller_path` is an # alias for `ActionMailer::Base#mailer_name`. Override `local_prefixes` # itself (the method actually in the call stack) so mailer view prefixes are # derived from the shareable class `name` directly — never touching # `@mailer_name`. if defined?(::ActionView::ViewPaths::ClassMethods) amod = ::ActionView::ViewPaths::ClassMethods unless amod.private_instance_methods.include?(:rrs_original_local_prefixes) amod.alias_method :rrs_original_local_prefixes, :local_prefixes end amod.class_eval do def local_prefixes if defined?(::ActionMailer::Base) && self <= ::ActionMailer::Base [name.underscore] else rrs_original_local_prefixes end end end end # The three internal callers of `mailer_name`: # * `collect_responses_from_templates` — template path lookup # * `default_i18n_subject` — I18n scope # * `instrument_payload` — ActiveSupport::Notifications payload # Re-implement each to use `self.class.name.underscore` (the same value # `mailer_name` would produce) without ever reading `@mailer_name`. # # Also make `PROTECTED_IVARS` (an unfrozen Array constant built at class-body # eval) shareable — otherwise a worker Ractor raises IsolationError reading # the non-shareable constant from `_protected_ivars`. unless ::Ractor.shareable?(::ActionMailer::Base::PROTECTED_IVARS) ::ActionMailer::Base.const_set(:PROTECTED_IVARS, ::Ractor.make_shareable(::ActionMailer::Base::PROTECTED_IVARS)) end ::ActionMailer::Base.class_eval do def collect_responses_from_templates(headers) templates_path = headers[:template_path] || (self.class.anonymous? ? "anonymous" : self.class.name.underscore) templates_name = headers[:template_name] || action_name each_template(Array(templates_path), templates_name).map do |template| format = template.format || self.formats.first { body: render(template: template, formats: [format]), content_type: Mime[format].to_s } end end def default_i18n_subject(interpolations = {}) mailer_scope = (self.class.anonymous? ? "anonymous" : self.class.name.underscore).tr("/", ".") I18n.t(:subject, **interpolations, scope: [mailer_scope, action_name], default: action_name.humanize) end def instrument_payload(key) { mailer: self.class.anonymous? ? "anonymous" : self.class.name.underscore, key: key } end # `ActionMailer::Base#config` (a `class_attribute` on # `AbstractController::Base`) resolves to `nil` inside a worker Ractor # because the shim's frozen shareable fallback doesn't cover this mailer # instance receiver. `ActionView::Helpers::ControllerHelper#assign_controller` # then calls `controller.config.inheritable_copy` on nil and raises. Fall # back to an empty `OrderedOptions` (the class_attribute's own default) so # mailer view setup proceeds; the real config is only needed for delivery, # which routes through `Mail.delivery_method` (patched separately). def config super || ActiveSupport::OrderedOptions.new end end end |
._install_active_model_attribute_method_patterns_patch ⇒ Object
ActiveModel::AttributeMethods::ClassMethods#attribute_method_patterns_cache stores a mutable Concurrent::Map in a CLASS instance variable (@attribute_method_patterns_cache). That ivar is unshareable, so reading it from a worker Ractor raises Ractor::IsolationError ("can not get unshareable values from instance variables of classes/modules from non-main Ractors") — hit on the write path via redirect_to @post -> respond_to? -> matched_attribute_method -> attribute_method_patterns_cache.
Unlike @relation_delegate_cache (populated once, freezable), this map is mutated lazily per method_name (compute_if_absent) during request handling, so it cannot be frozen. Instead route it through Ractor-local storage: each Ractor gets its own Concurrent::Map, shared by all of its threads. The cache content is deterministic (a pure function of the class's attribute_method_patterns), so per-Ractor recomputation is correct.
353 354 355 356 357 358 359 360 361 362 363 364 365 366 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 353 def _install_active_model_attribute_method_patterns_patch return if @am_amp_patched @am_amp_patched = true _register_patch :active_model_attribute_method_patterns, "8.1" return unless defined?(::ActiveModel::AttributeMethods) mod = ::ActiveModel::AttributeMethods::ClassMethods mod.module_eval do def attribute_method_patterns_cache store = Ractor.current[:__am_attribute_method_patterns_cache__] ||= {} store[object_id] ||= Concurrent::Map.new(initial_capacity: 4) end end end |
._install_active_model_attribute_patch ⇒ Object
See patches/active_model_attribute.rb. When the frozen :ractor graph is
built, each model class's _default_attributes template (and the
FromDatabase instances within it) is deep-frozen. Attribute#dup_or_share
returns self for immutable column types, so a worker's NEW record would
share a frozen Attribute and raise FrozenError on first read/write. This
patch makes a frozen receiver yield a fresh, mutable Attribute so writes
(POST/create) work in workers. No-op in normal (unfrozen) Rails.
Delegates to RactorRailsShim::Patches::ActiveModelAttribute.install
(extracted Step 22.2, Issue #22). The idempotency flag now lives on the
role object. See Patches::ActiveModelAttribute for the contract (the
three prepend targets + the ActiveModel::Attribute guard).
326 327 328 |
# File 'lib/ractor_rails_shim/patches/core.rb', line 326 def _install_active_model_attribute_patch Patches::ActiveModelAttribute.install end |
._install_active_model_conversion_patch ⇒ Object
Patch ActiveModel::Conversion::ClassMethods#_to_partial_path to route its
lazy class-ivar cache (@_to_partial_path ||= ...) through
IsolatedExecutionState. The cache holds a deterministic String derived
from model_name, so each Ractor can build its own. Without this, the
first render @posts / render post in a worker Ractor writes the class
ivar and dies with Ractor::IsolationError: can not set instance variables of classes/modules by non-main Ractors. Seen via ActionView's
CollectionRenderer#render_collection_derive_partial -> to_partial_path.
1289 1290 1291 1292 1293 1294 1295 1296 1297 1298 1299 1300 1301 1302 1303 1304 1305 1306 1307 1308 1309 1310 1311 1312 1313 1314 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 1289 def _install_active_model_conversion_patch return if @active_model_conversion_patched @active_model_conversion_patched = true _register_patch :active_model_conversion, "8.1" return unless defined?(::ActiveModel::Conversion) amc = ::ActiveModel::Conversion amc.module_eval <<-RUBY, __FILE__, __LINE__ + 1 module ClassMethods def _to_partial_path key = :"ractor_rails_shim_to_partial_path_\#{name}" v = RactorRailsShim.storage[key] return v if v path = if respond_to?(:model_name) "\#{model_name.collection}/\#{model_name.element}" else element = ActiveSupport::Inflector.underscore(ActiveSupport::Inflector.demodulize(name)) collection = ActiveSupport::Inflector.tableize(name) "\#{collection}/\#{element}" end path = path.freeze RactorRailsShim.storage[key] = path path end end RUBY end |
._install_active_model_naming_patch ⇒ Object
(an ActiveModel::Name holding unfrozen, unshareable Strings) on the model class. From a worker Ractor that write raises Ractor::IsolationError ("can not set instance variables of classes/modules by non-main Ractors") and reading the unshareable value raises too. Route the cache through IsolatedExecutionState (keyed by model object_id) so each Ractor builds and keeps its own ActiveModel::Name without touching the shared class ivar.
1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 1076 def _install_active_model_naming_patch return if @am_naming_patched @am_naming_patched = true _register_patch :active_model_naming, "8.1" return unless defined?(::ActiveModel::Naming) mod = ::ActiveModel::Naming mod.module_eval do def model_name if Ractor.main? @_model_name ||= _rrs_compute_model_name else store = (RactorRailsShim.storage[:rrs_model_names] ||= {}) store[object_id] ||= _rrs_compute_model_name end end private def _rrs_compute_model_name namespace = module_parents.detect do |n| n.respond_to?(:use_relative_model_naming?) && n.use_relative_model_naming? end ::ActiveModel::Name.new(self, namespace) end end end |
._install_active_record_core_patch ⇒ Object
Patch ActiveRecord::Core::ClassMethods#arel_table / #predicate_builder /
#type_caster. Each memoizes an unshareable value (@arel_table is an
Arel::Table, @predicate_builder a PredicateBuilder) on the shared model
class. From a worker Ractor the ||= write raises Ractor::IsolationError,
and reading the unshareable cached value also raises. Build + cache each
per-Ractor via IsolatedExecutionState (keyed by model object_id); main
keeps the original class-ivar behavior.
985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 985 def _install_active_record_core_patch return if @ar_core_patched @ar_core_patched = true _register_patch :active_record_core, "8.1" return unless defined?(::ActiveRecord::Core::ClassMethods) mod = ::ActiveRecord::Core::ClassMethods mod.module_eval do def arel_table if Ractor.main? @arel_table ||= ::Arel::Table.new(table_name, klass: self) else store = (RactorRailsShim.storage[:rrs_arel_tables] ||= {}) store.fetch(object_id) { store[object_id] = ::Arel::Table.new(table_name, klass: self) } end end def predicate_builder if Ractor.main? @predicate_builder ||= ::ActiveRecord::PredicateBuilder.new( ::ActiveRecord::TableMetadata.new(self, arel_table)) else store = (RactorRailsShim.storage[:rrs_predicate_builders] ||= {}) store.fetch(object_id) do store[object_id] = ::ActiveRecord::PredicateBuilder.new( ::ActiveRecord::TableMetadata.new(self, arel_table)) end end end def type_caster if Ractor.main? @type_caster ||= ::ActiveRecord::TypeCaster::Map.new(self) else store = (RactorRailsShim.storage[:rrs_type_casters] ||= {}) store.fetch(object_id) { store[object_id] = ::ActiveRecord::TypeCaster::Map.new(self) } end end # inspection_filter memoizes @inspection_filter as a class ivar on the # (shared, frozen) model class. From a worker Ractor that write raises # Ractor::IsolationError ("can not set instance variables of # classes/modules by non-main Ractors") — e.g. whenever a model is # inspected during a request (backtraces, journey route-formatter # Hash#inspect, debug output). Route the memoization through # IsolatedExecutionState keyed by the model class instead, preserving # the original superclass-delegation semantics. def inspection_filter store = (RactorRailsShim.storage[:rrs_inspection_filters] ||= {}) return store[self] if store.key?(self) result = if @filter_attributes.nil? superclass.inspection_filter else mask = ::ActiveRecord::Core.const_get(:InspectionMask).new(ActiveSupport::ParameterFilter::FILTERED) ActiveSupport::ParameterFilter.new(@filter_attributes, mask: mask) end store[self] = result end end end |
._install_active_record_inheritance_patch ⇒ Object
Patch ActiveRecord::Inheritance::ClassMethods#finder_needs_type_condition?.
It memoizes @finder_needs_type_condition (a Symbol) on the shared model
class via @ivar ||=. From a worker Ractor that write raises
Ractor::IsolationError. Route the value through IsolatedExecutionState
(keyed by model object_id); main keeps the original class-ivar behavior.
1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 1050 def _install_active_record_inheritance_patch return if @ar_inheritance_patched @ar_inheritance_patched = true _register_patch :active_record_inheritance, "8.1" return unless defined?(::ActiveRecord::Inheritance::ClassMethods) mod = ::ActiveRecord::Inheritance::ClassMethods mod.module_eval do def finder_needs_type_condition? if Ractor.main? :true == (@finder_needs_type_condition ||= descends_from_active_record? ? :false : :true) else store = (RactorRailsShim.storage[:rrs_finder_type_cond] ||= {}) store.fetch(object_id) do store[object_id] = descends_from_active_record? ? false : true end end end end end |
._install_active_record_model_schema_patch ⇒ Object
Patch ActiveRecord::ModelSchema::ClassMethods so worker Ractors do not
write the @table_name (and related) class ivars on the shared model
class. table_name memoizes via reset_table_name unless defined?(@table_name), and reset_table_name calls self.table_name =
which writes @table_name/@arel_table/etc. From a worker that write is
Ractor::IsolationError. Route the value through IsolatedExecutionState
(keyed by model object_id); main keeps the original class-ivar behavior.
NOTE on naming: there are TWO ModelSchema-related install methods, with deliberately different scopes:
* `_install_active_record_model_schema_patch` (this method) — patches
`table_name` / `table_name=` / `reset_table_name` only.
* `_install_activerecord_model_schema_patch` (below) — patches the
*column-derived* lazy caches `symbol_column_to_string`,
`content_columns` (and previously `column_defaults`, now provided
by the prepended `ActiveRecordModelSchemaPatch`).
Both target ActiveRecord::ModelSchema::ClassMethods but patch
disjoint method sets; do not collapse them without auditing every
caller.
837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 837 def _install_active_record_model_schema_patch return if @ar_model_schema_patched @ar_model_schema_patched = true _register_patch :active_record_model_schema, "8.1" return unless defined?(::ActiveRecord::ModelSchema::ClassMethods) mod = ::ActiveRecord::ModelSchema::ClassMethods mod.module_eval do # `columns_hash` reads `@columns_hash`. The original only loads via # `load_schema` when `@columns_hash` is nil. In a worker Ractor, class # instance variables are NOT shared with the main Ractor (reading # `@columns_hash` set in main raises Ractor::IsolationError, or — under # kino's worker Ractors, which do not share main's class-ivar space — # returns the worker's own nil view). So `load_schema!` (patched to a # no-op in workers) never populates it, and the model's schema is # permanently nil in workers. Mirror the worker `table_name` patch # (below): cache the loaded schema per-Ractor in IES # (ActiveSupport::IsolatedExecutionState, readable from every worker # Ractor) instead of the un-shareable class ivar. Main keeps the # original class-ivar path (frozen into the shared graph). def columns_hash if ::Ractor.main? || abstract_class? load_schema if @columns_hash.nil? @columns_hash else store = (RactorRailsShim.storage[:rrs_columns_hash] ||= {}) cached = store[self.object_id] return cached if cached cols = connection_pool.schema_cache.columns_hash(table_name) if cols.nil? || cols.empty? # Cache was cold (fresh worker pool). Force a load from the DB # via the (lazily established) worker connection. begin cols = connection.columns(table_name).index_by(&:name) rescue StandardError cols = nil end end cols = cols.freeze if cols store[self.object_id] = cols if cols cols end end def table_name if Ractor.main? reset_table_name unless defined?(@table_name) @table_name else store = (RactorRailsShim.storage[:rrs_table_names] ||= {}) store.fetch(object_id) { store[object_id] = compute_table_name } end end def table_name=(value) value = value && value.to_s if Ractor.main? if defined?(@table_name) return if value == @table_name reset_column_information if connected? end @table_name = value @arel_table = nil @sequence_name = nil unless @explicit_sequence_name @predicate_builder = nil else (RactorRailsShim.storage[:rrs_table_names] ||= {})[object_id] = value end end def reset_table_name if Ractor.main? super else table_name end end # `full_table_name_prefix` / `full_table_name_suffix` are reached from # `compute_table_name` whenever a model's `table_name` is NOT already # cached in the worker (e.g. ActiveStorage::Attachment / Blob, whose # explicit `table_name=` is set in main and so is invisible to a # worker's per-Ractor IES). Upstream they use # `module_parents.detect { |p| p.respond_to?(:table_name_prefix) }` — # the literal block is compiled in the main Ractor and is un-shareable, # so invoking it from a worker raises "defined with an un-shareable Proc # in a different Ractor". Reimplement with a plain `while` loop (no # block) that yields identical results. def full_table_name_prefix parents = module_parents i = 0 while i < parents.length p = parents[i] return p.table_name_prefix if p.respond_to?(:table_name_prefix) i += 1 end table_name_prefix || "" end def full_table_name_suffix parents = module_parents i = 0 while i < parents.length p = parents[i] return p.table_name_suffix if p.respond_to?(:table_name_suffix) i += 1 end table_name_suffix || "" end end # `attribute_names` is defined on `ActiveRecord::AttributeMethods:: # ClassMethods` (NOT ModelSchema), and that module sits earlier in the # ancestor chain, so a ModelSchema-side patch would be shadowed. Its main # path memoizes via `attribute_types` (an unshareable class ivar) and # `table_exists?` (writes `@table_exists`), both of which raise from a # worker Ractor. Patch it directly: main keeps the original via `super`; # workers use `columns_hash.keys` (already routed through per-Ractor # IES), a faithful substitute for the DB-backed attribute names. if defined?(::ActiveRecord::AttributeMethods::ClassMethods) am_mod = ::ActiveRecord::AttributeMethods::ClassMethods unless am_mod.method_defined?(:_rrs_orig_attribute_names) am_mod.alias_method(:_rrs_orig_attribute_names, :attribute_names) end am_mod.module_eval do def attribute_names if ::Ractor.main? _rrs_orig_attribute_names else key = :"rrs_attribute_names_#{object_id}" RactorRailsShim.storage[key] ||= if abstract_class? [] else columns_hash.keys end.freeze end end end end end |
._install_active_record_store_patch ⇒ Object
150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 |
# File 'lib/ractor_rails_shim/patches/active_record_store.rb', line 150 def self._install_active_record_store_patch return if @active_record_store_patched @active_record_store_patched = true # `ActiveRecord::Store` may not be loaded yet when this runs in a worker # Ractor (separate process) where ActiveRecord::Base is not defined at # install time. Force-load it so we can prepend BEFORE any model # (e.g. ActiveStorage::Blob) calls `store`, which generates the accessors # at class-definition time. Requiring it here is idempotent and harmless # for apps that don't use ActiveRecord. begin require "active_record/store" rescue LoadError return end return unless defined?(::ActiveRecord::Store::ClassMethods) ::ActiveRecord::Store::ClassMethods.prepend(ActiveRecordStorePatch) ::ActiveRecord::Store.prepend(ActiveRecordStoreInstancePatch) _register_patch :active_record_store, "8.1" end |
._install_active_storage_patch ⇒ Object
247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 |
# File 'lib/ractor_rails_shim/patches/active_storage.rb', line 247 def _install_active_storage_patch # Freeze the SecureRandom alphabets on every dispatch: they are defined # lazily during boot, so the first dispatch (early install) may run before # they exist while a later dispatch (prepare_for_ractors!, after boot) # finds them defined. `_install_secure_random_alphabets!` is itself # idempotent and only acts when needed. _install_secure_random_alphabets! return if @active_storage_patched @active_storage_patched = true @_as_patched_macros = false @_as_patched_blob = false _maybe_apply_active_storage_patch unless @_as_patched_macros && @_as_patched_blob # ActiveStorage's `:active_storage` load hook does not reliably fire in # every boot (e.g. a bare `config/application` + initialize! boot), and # the macro module must be patched BEFORE any app model calls # `has_one_attached`/`has_many_attached` (during eager-load). Watch for # the relevant modules to be opened with a TracePoint(:class); once each # constant is defined, prepend. ActiveStorage::Blob loads *after* # ActiveStorage::Attached::Model::ClassMethods, so it is patched in a # later trace event. @_as_tp = TracePoint.new(:class) do |tp| _maybe_apply_active_storage_patch if @_as_patched_macros && @_as_patched_blob @_as_tp.disable @_as_tp = nil end end @_as_tp.enable end end |
._install_active_support_error_reporter_patch ⇒ Object
Patch ActiveSupport module's @error_reporter class ivar (defined via
singleton_class.attr_accessor :error_reporter in active_support.rb:109)
to not read from a worker Ractor. ExecutionWrapper.error_reporter delegates
to ActiveSupport.error_reporter, which reads the @error_reporter ivar on
the ActiveSupport module. Workers get a fresh ErrorReporter (no subscribers
— correct for a read-only shared app where error reporting already ran
in main via the Rails.error mechanism). Called per-request via
ActionDispatch::Executor middleware.
212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 |
# File 'lib/ractor_rails_shim/patches/active_support.rb', line 212 def _install_active_support_error_reporter_patch return if @error_reporter_patched @error_reporter_patched = true _register_patch :error_reporter, "8.1" return unless defined?(::ActiveSupport) er_key = :ractor_rails_shim_active_support_error_reporter er_key_str = er_key.inspect ::ActiveSupport.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def error_reporter v = RactorRailsShim.storage[#{er_key_str}] return v if RactorRailsShim.storage.key?(#{er_key_str}) if Ractor.main? && instance_variable_defined?(:@error_reporter) @error_reporter else built = ActiveSupport::ErrorReporter.new RactorRailsShim.storage[#{er_key_str}] = built built end end def error_reporter=(val) RactorRailsShim.storage[#{er_key_str}] = val @error_reporter = val if Ractor.main? val end RUBY end |
._install_activerecord_autosave_patch ⇒ Object
Patch ActiveRecord::AutosaveAssociation to redefine the
autosave_associated_records_for_<assoc> and
validate_associated_records_for_<assoc> methods via string eval (compiled
def, no captured binding) instead of Rails' define_method(&block).
The original block captures a binding from the main Ractor (where
add_autosave_association_callbacks runs during eager load), so calling
the method from a worker Ractor raises "defined with an un-shareable Proc
in a different Ractor". The reflection object itself IS shareable
(frozen as part of the shared app graph), so we store it in a frozen
shareable registry and emit a string-eval'd def that looks it up at call
time. The cyclic-guard logic from define_non_cyclic_method is inlined
into the string body.
MUST install BEFORE models are eager-loaded, because
add_autosave_association_callbacks fires during belongs_to/has_many/
has_one evaluation at boot. We alias the original method, call it (which
registers the callback AND creates the unshareable method), then
immediately overwrite the method with a string-eval'd version.
2505 2506 2507 2508 2509 2510 2511 2512 2513 2514 2515 2516 2517 2518 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 2505 def _install_activerecord_autosave_patch return if @ar_autosave_patched @ar_autosave_patched = true _register_patch :activerecord_autosave, "8.1" # Frozen shareable registry: { [model_name, method_name] => reflection }. # Rebuilt atomically (like SCOPE_SOURCE_CODES) as each association is # declared during boot. unless RactorRailsShim.const_defined?(:SHAREABLE_AUTOSAVE_REFLECTIONS, false) RactorRailsShim.const_set(:SHAREABLE_AUTOSAVE_REFLECTIONS, Ractor.make_shareable({})) end _apply_activerecord_autosave_patch end |
._install_activerecord_configurations_patch ⇒ Object
Patch ActiveRecord::Core.configurations / configurations= to route the
raw @@configurations class variable (which a non-main Ractor cannot
read or write) through IsolatedExecutionState, with a shareable
(deep-frozen) fallback for worker Ractors. Connection establishment in a
worker (ConnectionHandler#establish_connection -> resolve_pool_config
-> ActiveRecord::Base.configurations) otherwise dies on the class
variable. Captured in the main Ractor at prepare/make-shareable time.
1323 1324 1325 1326 1327 1328 1329 1330 1331 1332 1333 1334 1335 1336 1337 1338 1339 1340 1341 1342 1343 1344 1345 1346 1347 1348 1349 1350 1351 1352 1353 1354 1355 1356 1357 1358 1359 1360 1361 1362 1363 1364 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 1323 def _install_activerecord_configurations_patch return if @ar_configurations_patched @ar_configurations_patched = true _register_patch :activerecord_configurations, "8.1" return unless defined?(::ActiveRecord::Core) if Ractor.main? begin cfg = ::ActiveRecord::Base.configurations cfg = Ractor.make_shareable(cfg) if cfg if cfg _reassign_shareable_const(:AR_CONFIGURATIONS_SHAREABLE, cfg) end rescue StandardError => e # best-effort end end key = :ractor_rails_shim_ar_configurations key_str = key.inspect # ActiveRecord::Base gets `configurations` via ActiveSupport::Concern's # `class_methods` (it copies the method onto Base's singleton class), so # patching Core.singleton_class alone is not enough — redefine on Base's # singleton class directly. `@@configurations` resolves through the # include chain (Core's class var) in the main ractor; the worker branch # never touches the class var. ::ActiveRecord::Base.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def configurations v = RactorRailsShim.storage[#{key_str}] return v if RactorRailsShim.storage.key?(#{key_str}) if Ractor.main? ActiveRecord::Core.class_variable_get(:@@configurations) else RactorRailsShim::AR_CONFIGURATIONS_SHAREABLE end end def configurations=(config) RactorRailsShim.storage[#{key_str}] = config ActiveRecord::Core.class_variable_set(:@@configurations, config) if Ractor.main? end RUBY end |
._install_activerecord_connection_handler_patch ⇒ Object
Patch ActiveRecord::Base to route default_connection_handler through IES, and ensure connection_handler returns the per-Ractor handler. In the main ractor, falls back to the original default_connection_handler (set at core.rb:248). In workers, falls back to nil (correct — workers must call init_worker_ar_connections! to establish their own handler). Also patches retrieve_connection / connected? / connection_pool to tolerate nil handler (raise a clear error message instead of NoMethodError on nil).
1773 1774 1775 1776 1777 1778 1779 1780 1781 1782 1783 1784 1785 1786 1787 1788 1789 1790 1791 1792 1793 1794 1795 1796 1797 1798 1799 1800 1801 1802 1803 1804 1805 1806 1807 1808 1809 1810 1811 1812 1813 1814 1815 1816 1817 1818 1819 1820 1821 1822 1823 1824 1825 1826 1827 1828 1829 1830 1831 1832 1833 1834 1835 1836 1837 1838 1839 1840 1841 1842 1843 1844 1845 1846 1847 1848 1849 1850 1851 1852 1853 1854 1855 1856 1857 1858 1859 1860 1861 1862 1863 1864 1865 1866 1867 1868 1869 1870 1871 1872 1873 1874 1875 1876 1877 1878 1879 1880 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 1773 def _install_activerecord_connection_handler_patch return if @ar_conn_handler_patched @ar_conn_handler_patched = true _register_patch :activerecord_connection_handler, "8.1" return unless defined?(::ActiveRecord::ConnectionHandling) # Capture configs at install time if AR is already loaded (main ractor). _capture_ar_configurations! if Ractor.main? # Capture the main ractor's default_connection_handler value BEFORE we # override the method below (the override shadows the original # class_attribute reader). The class_attribute reader (already patched # by the shim) routes through IES, so read the value now. Store it in # CLASS_ATTR_VALUES so the patched reader can find it, and seed IES so # connection_handler finds it immediately. dch_key = :ractor_rails_shim_ar_default_connection_handler dch_key_str = dch_key.inspect if Ractor.main? begin orig_handler = ::ActiveRecord::Base.default_connection_handler if orig_handler RactorRailsShim::Registry.class_attr_values[:__ractor_rails_shim_ar_default_connection_handler__] = orig_handler RactorRailsShim.storage[dch_key] = orig_handler end rescue StandardError => e # Best-effort end end # Patch default_connection_handler to route through IES. # The class_attribute reader for default_connection_handler is already # patched by the shim (it's in the known-unshareable skip list → nil # in workers). We override the class method to return the per-Ractor # handler if set, then fall back to the original (main only) or nil. ::ActiveRecord::Base.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def default_connection_handler v = RactorRailsShim.storage[#{dch_key_str}] return v if RactorRailsShim.storage.key?(#{dch_key_str}) if Ractor.main? cv = RactorRailsShim::CLASS_ATTR_VALUES[:__ractor_rails_shim_ar_default_connection_handler__] return cv if cv end nil end def default_connection_handler=(val) RactorRailsShim.storage[#{dch_key_str}] = val end # Route the per-Ractor handler through Ractor-local storage. IES is # per-thread, so a handler stored on the init thread is invisible to the # worker's other threads; Ractor.current is per-Ractor and shared by all # threads of the worker. Falls back to IES (legacy) then # default_connection_handler (main Ractor only). # NOTE: uses nil-sentinel (`unless v.nil?`) rather than key?-based # detection because Ractor#[] has no `key?` method (Ractor local # storage returns nil for both unset and set-to-nil). Safe here # because `connection_handler=` is only ever called with a real # ConnectionHandler instance, never nil — so nil unambiguously # means "unset, fall through to default_connection_handler". def connection_handler v = Ractor.current[:active_record_connection_handler] return v unless v.nil? RactorRailsShim.storage[:active_record_connection_handler] || default_connection_handler end def connection_handler=(handler) Ractor.current[:active_record_connection_handler] = handler end RUBY # Also ensure connection_handler (which Rails already routes through IES # at core.rb:132-138) works. Rails' implementation: # def self.connection_handler # RactorRailsShim.storage[:active_record_connection_handler] || default_connection_handler # end # This is already correct — if the worker sets the IES key via # init_worker_ar_connections!, connection_handler returns it. If not, # it falls back to default_connection_handler (nil in workers). # # We just need to make sure connection_pool / retrieve_connection # give a clear error message when the handler is nil (instead of # NoMethodError: undefined method `retrieve_connection_pool' for nil). ::ActiveRecord::ConnectionHandling.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def connection_pool handler = connection_handler unless handler raise ActiveRecord::ConnectionNotEstablished, "No connection handler for Ractor \#{Ractor.current.object_id}. " \ "Call RactorRailsShim.init_worker_ar_connections! in each worker " \ "Ractor before serving requests." end handler.retrieve_connection_pool(connection_specification_name, role: current_role, shard: current_shard, strict: true) end def retrieve_connection handler = connection_handler unless handler raise ActiveRecord::ConnectionNotEstablished, "No connection handler for Ractor \#{Ractor.current.object_id}. " \ "Call RactorRailsShim.init_worker_ar_connections! in each worker " \ "Ractor before serving requests." end handler.retrieve_connection(connection_specification_name, role: current_role, shard: current_shard) end def connected? handler = connection_handler return false unless handler handler.connected?(connection_specification_name, role: current_role, shard: current_shard) end RUBY end |
._install_activerecord_db_config_handlers_patch ⇒ Object
Patch ActiveRecord::DatabaseConfigurations.db_config_handlers (a
singleton_class.attr_accessor, i.e. a class instance variable on the
DatabaseConfigurations class) to route through IES with a shareable
fallback. The value is an Array of adapter-registered handler Procs
(register_db_config_handler { |env,name,url,config| ... }). Class
instance variables are per-Ractor, so a worker's slot is empty even if
main set one — and the worker cannot read main's. The handler Procs
themselves CAN be made shareable (verified: the sqlite3 handler captures
only the shareable HashConfig constant), so we deep-freeze the Array +
each Proc in main and expose it as a shareable constant the worker reads.
ConnectionHandler#establish_connection -> resolve_pool_config ->
DatabaseConfigurations#resolve -> build_db_config_from_hash calls
these Procs, so they must be shareable AND callable cross-Ractor.
1379 1380 1381 1382 1383 1384 1385 1386 1387 1388 1389 1390 1391 1392 1393 1394 1395 1396 1397 1398 1399 1400 1401 1402 1403 1404 1405 1406 1407 1408 1409 1410 1411 1412 1413 1414 1415 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 1379 def _install_activerecord_db_config_handlers_patch return if @ar_dbch_patched @ar_dbch_patched = true _register_patch :activerecord_db_config_handlers, "8.1" return unless defined?(::ActiveRecord::DatabaseConfigurations) if Ractor.main? begin handlers = ::ActiveRecord::DatabaseConfigurations.db_config_handlers # Make each handler Proc shareable (freezes its binding). A shareable # Proc is callable from any Ractor. handlers.each { |h| _swallow("make ar db config handler shareable") { Ractor.make_shareable(h) } } shareable = Ractor.make_shareable(handlers.dup) _reassign_shareable_const(:AR_DB_CONFIG_HANDLERS_SHAREABLE, shareable) rescue StandardError => e # best-effort end end key = :ractor_rails_shim_ar_db_config_handlers key_str = key.inspect ::ActiveRecord::DatabaseConfigurations.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def db_config_handlers v = RactorRailsShim.storage[#{key_str}] return v if RactorRailsShim.storage.key?(#{key_str}) if Ractor.main? @db_config_handlers else RactorRailsShim::AR_DB_CONFIG_HANDLERS_SHAREABLE end end def db_config_handlers=(val) RactorRailsShim.storage[#{key_str}] = val @db_config_handlers = val if Ractor.main? end RUBY end |
._install_activerecord_deduplicable_patch ⇒ Object
Patch Deduplicable::ClassMethods#registry to route the lazy class instance
variable @registry through IES. registry returns @registry ||= {} —
a mutable Hash used to deduplicate column metadata objects. It's called
during schema introspection (Post.all -> columns -> new_column_from_field
-> fetch_type_metadata -> Deduplicable.new -> deduplicate -> registry).
The class instance variable write fails from a non-main Ractor.
Fix: route through IES so each Ractor builds its own registry Hash.
1522 1523 1524 1525 1526 1527 1528 1529 1530 1531 1532 1533 1534 1535 1536 1537 1538 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 1522 def _install_activerecord_deduplicable_patch return if @ar_deduplicable_patched @ar_deduplicable_patched = true _register_patch :activerecord_deduplicable, "8.1" return unless defined?(::ActiveRecord::ConnectionAdapters::Deduplicable) ::ActiveRecord::ConnectionAdapters::Deduplicable::ClassMethods.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def registry key = :"ractor_rails_shim_dedup_registry_\#{name || object_id}" v = RactorRailsShim.storage[key] return v if RactorRailsShim.storage.key?(key) h = {} RactorRailsShim.storage[key] = h h end RUBY end |
._install_activerecord_define_attribute_methods_patch ⇒ Object
Patch ActiveRecord::AttributeMethods::ClassMethods#define_attribute_methods to no-op in worker Ractors. The method writes @attribute_methods_generated (a class ivar) and calls load_schema + super(attribute_names) which generate methods on the class. In a worker, all of these either write class ivars (IsolationError) or try to define methods on a frozen class (FrozenError). The attribute methods were already generated in main by generate_ar_attribute_methods!, so workers just need to skip this.
798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 798 def _install_activerecord_define_attribute_methods_patch return if @ar_define_attr_methods_patched @ar_define_attr_methods_patched = true _register_patch :activerecord_define_attribute_methods, "8.1" return unless defined?(::ActiveRecord::AttributeMethods::ClassMethods) ::ActiveRecord::AttributeMethods::ClassMethods.module_eval do alias_method :_rrs_orig_define_attribute_methods, :define_attribute_methods def define_attribute_methods if Ractor.main? _rrs_orig_define_attribute_methods else # Worker: attribute methods were generated in main. Return true # (the original returns true on success) without writing class ivars. true end end end end |
._install_activerecord_delegation_patch ⇒ Object
Patch ActiveRecord::Delegation.uncacheable_methods to route the lazy class instance variable @uncacheable_methods through IES.
uncacheable_methods is a class method on the Delegation module:
`@uncacheable_methods ||= (delegated_classes.flat_map(&:public_instance_methods)
- Relation.public_instance_methods).to_set.freeze
. It's read duringmethod_missingon relation delegate classes (e.g. when Kaminari callsPost.page(1).per(10)—perisn't a standard Relation method, soClassSpecificRelation#method_missingchecksuncacheable_methods` to decide whether to delegate). The class instance variable write fails from a non-main Ractor (Ractor::IsolationError).
Fix: route through IES so each Ractor computes + caches its own Set. The computation is deterministic (same delegated_classes everywhere). String-eval'd (no captured binding), callable from any Ractor.
1931 1932 1933 1934 1935 1936 1937 1938 1939 1940 1941 1942 1943 1944 1945 1946 1947 1948 1949 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 1931 def _install_activerecord_delegation_patch return if @ar_delegation_patched @ar_delegation_patched = true _register_patch :activerecord_delegation, "8.1" return unless defined?(::ActiveRecord::Delegation) ::ActiveRecord::Delegation.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def uncacheable_methods key = :ractor_rails_shim_ar_uncacheable_methods v = RactorRailsShim.storage[key] return v if RactorRailsShim.storage.key?(key) result = ( delegated_classes.flat_map(&:public_instance_methods) - ActiveRecord::Relation.public_instance_methods ).to_set.freeze RactorRailsShim.storage[key] = result result end RUBY end |
._install_activerecord_find_by_cache_patch ⇒ Object
ActiveRecord::Base#cached_find_by_statement reads
@find_by_statement_cache[connection.prepared_statements] (a Hash whose
values are Concurrent::Maps) and calls cache.compute_if_absent(key).
Concurrent::Map is unshareable, so make_app_shareable! replaces the
maps with frozen Hashes whose values end up nil — and Hash has no
compute_if_absent anyway. In a worker Ractor this raises
NoMethodError: undefined method 'compute_if_absent' for nil, breaking
find / find_by / take (but not where, which doesn't use the
cache). Fix: in non-main Ractors, build the per-find-statement cache in
IsolatedExecutionState (per-Ractor, mutable) keyed by the model class
and connection.prepared_statements. Main keeps the original
Concurrent::Map-backed behavior via super.
2029 2030 2031 2032 2033 2034 2035 2036 2037 2038 2039 2040 2041 2042 2043 2044 2045 2046 2047 2048 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 2029 def _install_activerecord_find_by_cache_patch return if @activerecord_find_by_cache_patched @activerecord_find_by_cache_patched = true _register_patch :activerecord_find_by_cache, "8.1" return unless defined?(::ActiveRecord::Base) ::ActiveRecord::Base.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def cached_find_by_statement(connection, key, &block) return super if Ractor.main? cache = (RactorRailsShim.storage[:"ractor_rails_shim_find_by_cache_\#{object_id}"] ||= {}) prepared = connection.prepared_statements sub = (cache[prepared] ||= {}) if sub.key?(key) sub[key] else sub[key] = ::ActiveRecord::StatementCache.create(connection, &block) end end RUBY end |
._install_activerecord_migration_patch ⇒ Object
Patch ActiveRecord::Migrator.migrations_paths (a singleton attr_accessor
reading the @migrations_paths class ivar) and
ActiveRecord::Migration::CheckPending (the dev pending-migration
middleware) to be Ractor-safe. In dev, CheckPending runs on every request
and reads Migrator.migrations_paths + mutates its own @watcher /
@needs_check ivars on the (frozen, shared) middleware instance. Route
the class ivar and the instance ivars through IsolatedExecutionState so
each worker reads the main Ractor's migrations paths and builds its own
watcher. This keeps the dev pending-migration guard working under kino
:ractor instead of stripping the middleware.
2183 2184 2185 2186 2187 2188 2189 2190 2191 2192 2193 2194 2195 2196 2197 2198 2199 2200 2201 2202 2203 2204 2205 2206 2207 2208 2209 2210 2211 2212 2213 2214 2215 2216 2217 2218 2219 2220 2221 2222 2223 2224 2225 2226 2227 2228 2229 2230 2231 2232 2233 2234 2235 2236 2237 2238 2239 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 2183 def _install_activerecord_migration_patch return if @activerecord_migration_patched @activerecord_migration_patched = true _register_patch :activerecord_migration, "8.1" return unless defined?(::ActiveRecord::Migrator) mig = ::ActiveRecord::Migrator mp_key = :ractor_rails_shim_migrator_migrations_paths mp_key_str = mp_key.inspect mig.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def migrations_paths v = RactorRailsShim.storage[#{mp_key_str}] return v if RactorRailsShim.storage.key?(#{mp_key_str}) if Ractor.main? && instance_variable_defined?(:@migrations_paths) v = @migrations_paths RactorRailsShim.storage[#{mp_key_str}] = v v else ["db/migrate"].freeze end end def migrations_paths=(val) RactorRailsShim.storage[#{mp_key_str}] = val @migrations_paths = val if Ractor.main? val end RUBY return unless defined?(::ActiveRecord::Migration::CheckPending) cp = ::ActiveRecord::Migration::CheckPending w_key = :ractor_rails_shim_check_pending_watcher nc_key = :ractor_rails_shim_check_pending_needs_check w_key_str = w_key.inspect nc_key_str = nc_key.inspect cp.class_eval <<-RUBY, __FILE__, __LINE__ + 1 def call(env) @mutex.synchronize do watcher = RactorRailsShim.storage[#{w_key_str}] if watcher.nil? watcher = RactorRailsShim.storage[#{w_key_str}] = build_watcher do RactorRailsShim.storage[#{nc_key_str}] = true ::ActiveRecord::Migration.check_pending_migrations RactorRailsShim.storage[#{nc_key_str}] = false end end needs_check = RactorRailsShim.storage[#{nc_key_str}] needs_check = true if needs_check.nil? if needs_check watcher.execute else watcher.execute_if_updated end end @app.call(env) end RUBY end |
._install_activerecord_model_classes_patch ⇒ Object
Register + run the model-class shareability patch (Blocker: AR model class lazy class-ivar initialization from workers).
782 783 784 785 786 787 788 789 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 782 def _install_activerecord_model_classes_patch return if @ar_model_classes_patched @ar_model_classes_patched = true _register_patch :activerecord_model_classes, "8.1" return unless defined?(::ActiveRecord::Base) _share_model_classes! if Ractor.main? _share_active_record_internals! if Ractor.main? end |
._install_activerecord_model_schema_patch ⇒ Object
Patch ActiveRecord::ModelSchema::ClassMethods lazy class-ivar caches
(symbol_column_to_string, content_columns, column_defaults) to
route through IsolatedExecutionState. Each Ractor builds its own cache
(deterministic from columns/columns_hash, which are warmed in main
and read-only in workers). Without this, the first worker call that
misses the cache tries to WRITE the class ivar (@symbol_column_to_string_name_hash ||= ...) and dies with Ractor::IsolationError: can not set instance variables of classes/modules by non-main Ractors. Seen via Devise's
clean_up_passwords -> respond_to? -> symbol_column_to_string.
1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 1253 1254 1255 1256 1257 1258 1259 1260 1261 1262 1263 1264 1265 1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 1276 1277 1278 1279 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 1112 def _install_activerecord_model_schema_patch return if @ar_model_schema_symbol_patched @ar_model_schema_symbol_patched = true _register_patch :activerecord_model_schema, "8.1" return unless defined?(::ActiveRecord::ModelSchema) ::ActiveRecord::ModelSchema::ClassMethods.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def symbol_column_to_string(name_symbol) key = :"ractor_rails_shim_symbol_column_to_string_\#{self.name}" v = RactorRailsShim.storage[key] return v[name_symbol] if v hash = column_names.index_by(&:to_sym) RactorRailsShim.storage[key] = hash hash[name_symbol] end def content_columns key = :"ractor_rails_shim_content_columns_\#{self.name}" v = RactorRailsShim.storage[key] return v if v cols = columns.reject do |c| c.name == primary_key || c.name == inheritance_column || c.name.end_with?("_id", "_count") end.freeze RactorRailsShim.storage[key] = cols cols end # In a worker Ractor, load_schema and load_schema! must not write # class ivars (@columns_hash, @schema_loaded, etc.). The schema was # loaded in the main Ractor and the values are either frozen in the # shared graph or routed through IES. Patch load_schema to no-op in # workers (the schema is already loaded), and load_schema! to be a # no-op (never called from a worker because load_schema short-circuits). alias_method :_rrs_orig_load_schema, :load_schema def load_schema return unless Ractor.main? _rrs_orig_load_schema end alias_method :_rrs_orig_load_schema_bang, :load_schema! def load_schema! return unless Ractor.main? _rrs_orig_load_schema_bang end # NOTE: `column_defaults` is intentionally NOT redefined here. It is # installed by the prepended `ActiveRecordModelSchemaPatch` (see # `active_record_model_schema.rb`), which is prepended onto # `ModelSchema::ClassMethods` by `_install_active_model_attribute_patch` # and therefore sits in front of this module_eval heredoc in the # method lookup chain. A second definition here would be dead code and # a maintenance trap. # `reload_schema_from_cache` (called by `load_schema`/`reload_schema!`) # writes a batch of class ivars to `nil` to invalidate schema caches: # @_returning_columns_for_insert, @arel_table, @column_names, # @symbol_column_to_string_name_hash, @content_columns, @column_defaults # (ModelSchema) plus @timestamp_attributes_for_create_in_model, # @timestamp_attributes_for_update_in_model, # @all_timestamp_attributes_in_model (Timestamp#reload_schema_from_cache # via super). In a worker Ractor those writes raise # Ractor::IsolationError ("can not set instance variables of # classes/modules by non-main Ractors"). The shim already routes the # *readers* for these caches through IES, so in a worker we only need # to clear the IES slots — the next read rebuilds lazily. In main we # keep the original class-ivar-clearing behavior. alias_method :_rrs_orig_reload_schema_from_cache, :reload_schema_from_cache def reload_schema_from_cache(recursive = true) if Ractor.main? # During warming (prepare_for_ractors!/make_app_shareable!), # reload_schema_from_cache on abstract parents recursively resets # @schema_loaded = false and @columns_hash = nil on all descendants, # destroying schema data that workers need. Use a module-level flag # to suppress the recursive reset during the warming phase. The # flag is set by _share_model_classes! and generate_ar_attribute_methods!. unless RactorRailsShim.instance_variable_get(:@_rrs_schema_warming) _rrs_orig_reload_schema_from_cache(recursive) end else # Clear this Ractor's IES slots for the IES-routed schema caches. # Use `next` over an explicit list (not `IES.clear`) to avoid # wiping unrelated slots. Keys mirror the readers above + the # prepended ActiveRecordModelSchemaPatch (active_record_model_schema.rb) # + ActiveModelAttributeRegistrationPatch (active_model_attribute.rb). name_str = self.name [ :"ractor_rails_shim_symbol_column_to_string_\#{name_str}", :"ractor_rails_shim_content_columns_\#{name_str}", :"rrs_column_defaults_\#{object_id}", :"rrs_attributes_builder_\#{object_id}", :"rrs_yaml_encoder_\#{object_id}", :"rrs_returning_cols_\#{object_id}", :"rrs_default_attributes_\#{object_id}", :"rrs_attribute_types_\#{object_id}", :"rrs_table_names", :"rrs_arel_tables", :"rrs_predicate_builders", :"rrs_type_casters", ].each do |k| RactorRailsShim.storage.delete(k) end # The Timestamp subclass override calls `super` (this method); it # has already run by the time we get here via the super chain, so # its ivar-clears were intercepted by the worker branch of THIS # override (no-op). No further action needed. end end RUBY # Patch ActiveRecord::Timestamp::ClassMethods#reload_schema_from_cache # the same way: in main, keep the original ivar-clearing; in a worker, # skip the class-ivar writes (the IES slots are cleared by the # ModelSchema#reload_schema_from_cache override above via super). if defined?(::ActiveRecord::Timestamp::ClassMethods) ::ActiveRecord::Timestamp::ClassMethods.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def reload_schema_from_cache(recursive = true) if Ractor.main? @timestamp_attributes_for_create_in_model = nil @timestamp_attributes_for_update_in_model = nil @all_timestamp_attributes_in_model = nil super else # Worker: skip the class-ivar writes (would raise # IsolationError). The ModelSchema super clears the IES slots. super end end # The three timestamp-attribute readers memoize their result on the # (shared, frozen) model class via @timestamp_attributes_for_* class # ivars. From a worker Ractor that write raises Ractor::IsolationError # (this is exactly the failure hit on Comment#save -> # _create_record -> all_timestamp_attributes_in_model). Route the # memoization through a per-worker cache in workers; keep the original # class-ivar behavior in main (where the value is frozen into the # shared graph and the ivar can still be written before freeze). def timestamp_attributes_for_create_in_model if Ractor.main? @timestamp_attributes_for_create_in_model ||= (timestamp_attributes_for_create & column_names).freeze else (Ractor.current[:__rrs_ts_cache__] ||= {})[[object_id, :ts_create]] ||= (timestamp_attributes_for_create & column_names).freeze end end def timestamp_attributes_for_update_in_model if Ractor.main? @timestamp_attributes_for_update_in_model ||= (timestamp_attributes_for_update & column_names).freeze else (Ractor.current[:__rrs_ts_cache__] ||= {})[[object_id, :ts_update]] ||= (timestamp_attributes_for_update & column_names).freeze end end def all_timestamp_attributes_in_model if Ractor.main? @all_timestamp_attributes_in_model ||= (timestamp_attributes_for_create_in_model + timestamp_attributes_for_update_in_model).freeze else (Ractor.current[:__rrs_ts_cache__] ||= {})[[object_id, :ts_all]] ||= (timestamp_attributes_for_create_in_model + timestamp_attributes_for_update_in_model).freeze end end RUBY end end |
._install_activerecord_module_attrs_patch ⇒ Object
Patch ActiveRecord module-level singleton_class.attr_accessor attributes
(schema_cache_ignored_tables, database_cli, etc.) to route through IES
with shareable fallbacks. These are class instance variables on the
ActiveRecord module that workers can't read/write. Each is an Array or
Hash of simple literals, so they can be deep-frozen and shared.
1471 1472 1473 1474 1475 1476 1477 1478 1479 1480 1481 1482 1483 1484 1485 1486 1487 1488 1489 1490 1491 1492 1493 1494 1495 1496 1497 1498 1499 1500 1501 1502 1503 1504 1505 1506 1507 1508 1509 1510 1511 1512 1513 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 1471 def _install_activerecord_module_attrs_patch return if @ar_module_attrs_patched @ar_module_attrs_patched = true _register_patch :activerecord_module_attrs, "8.1" return unless defined?(::ActiveRecord) # [method_name, const_name] pairs. The const holds the shareable snapshot. attrs = [ [:schema_cache_ignored_tables, :AR_SCHEMA_CACHE_IGNORED_TABLES_SHAREABLE], [:database_cli, :AR_DATABASE_CLI_SHAREABLE], ] attrs.each do |method_name, const_name| if Ractor.main? begin val = ::ActiveRecord.public_send(method_name) shareable = Ractor.make_shareable(val.is_a?(::Array) ? val.dup : val) _reassign_shareable_const(const_name, shareable) rescue StandardError => e # best-effort end end key = :"ractor_rails_shim_ar_#{method_name}" key_str = key.inspect const_str = const_name.to_s ::ActiveRecord.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def #{method_name} v = RactorRailsShim.storage[#{key_str}] return v if RactorRailsShim.storage.key?(#{key_str}) if Ractor.main? @#{method_name} else RactorRailsShim::#{const_str} end end def #{method_name}=(val) RactorRailsShim.storage[#{key_str}] = val @#{method_name} = val if Ractor.main? end RUBY end end |
._install_activerecord_pool_config_patch ⇒ Object
Patch ActiveRecord::ConnectionAdapters::PoolConfig#initialize to skip
writing to the INSTANCES ObjectSpace::WeakMap registry in non-main
Ractors. This is the first wall a worker hits when establishing a
connection (ConnectionHandler#establish_connection ->
resolve_pool_config -> PoolConfig.new -> INSTANCES[self] = self).
INSTANCES is a private_constant ObjectSpace::WeakMap. A WeakMap is
intrinsically unshareable (it can't be frozen / made shareable), and a
non-main Ractor cannot access the constant at all (Ractor::IsolationError:
"can not access non-shareable objects in constant ... by non-main ractor").
The registry is ONLY used by the class methods discard_pools! and
disconnect_all! (which iterate all pool configs to disconnect/reload).
Those are called during reloading (dev) and explicit disconnect — never
in a read-only production worker serving requests. So skipping the
registry write in workers is safe: workers manage their own per-Ractor
handler + pools, and the main ractor's registry stays intact for reload.
We redefine initialize via string eval (no captured binding) so it's
callable from any Ractor. The body replicates the original exactly except
the final INSTANCES[self] = self is guarded by Ractor.main?. The
private INSTANCES constant is accessible via constant lookup because
the method is defined on PoolConfig itself.
1597 1598 1599 1600 1601 1602 1603 1604 1605 1606 1607 1608 1609 1610 1611 1612 1613 1614 1615 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 1597 def _install_activerecord_pool_config_patch return if @ar_pool_config_patched @ar_pool_config_patched = true _register_patch :activerecord_pool_config, "8.1" return unless defined?(::ActiveRecord::ConnectionAdapters::PoolConfig) ::ActiveRecord::ConnectionAdapters::PoolConfig.class_eval <<-RUBY, __FILE__, __LINE__ + 1 def initialize(connection_class, db_config, role, shard) super() @server_version = nil self.connection_descriptor = connection_class @db_config = db_config @role = role @shard = shard @pool = nil INSTANCES[self] = self if Ractor.main? end RUBY end |
._install_activerecord_primary_key_patch ⇒ Object
Patch ActiveRecord::AttributeMethods::PrimaryKey#primary_key and #composite_primary_key? to not read the PRIMARY_KEY_NOT_SET constant.
The original code: reset_primary_key if PRIMARY_KEY_NOT_SET.equal?(@primary_key)
reads the constant on every call. PRIMARY_KEY_NOT_SET is a BasicObject
(can't be frozen, can't be made shareable), so reading the constant from
a worker Ractor raises Ractor::IsolationError — even if @primary_key is
already set to the real value.
Fix: replace the sentinel check with a shareable-snapshot lookup. At _share_model_classes! time, each model's primary_key is warmed in main and stored in AR_PRIMARY_KEYS_SHAREABLE (a frozen Hash). The patched primary_key method checks IES first (per-Ractor), then the shareable snapshot, then falls back to the original logic in the main ractor. Workers never read the constant. String-eval'd (no captured binding).
1966 1967 1968 1969 1970 1971 1972 1973 1974 1975 1976 1977 1978 1979 1980 1981 1982 1983 1984 1985 1986 1987 1988 1989 1990 1991 1992 1993 1994 1995 1996 1997 1998 1999 2000 2001 2002 2003 2004 2005 2006 2007 2008 2009 2010 2011 2012 2013 2014 2015 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 1966 def _install_activerecord_primary_key_patch return if @ar_primary_key_patched @ar_primary_key_patched = true _register_patch :activerecord_primary_key, "8.1" return unless defined?(::ActiveRecord::AttributeMethods::PrimaryKey::ClassMethods) mod = ::ActiveRecord::AttributeMethods::PrimaryKey::ClassMethods mod.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def primary_key key = :"ractor_rails_shim_pk_\#{name || object_id}" v = RactorRailsShim.storage[key] return v if RactorRailsShim.storage.key?(key) if Ractor.main? reset_primary_key if PRIMARY_KEY_NOT_SET.equal?(@primary_key) v = @primary_key RactorRailsShim.storage[key] = v v else snap = RactorRailsShim::AR_PRIMARY_KEYS_SHAREABLE[name] return snap if snap # Fallback: derive the primary key from the schema cache via the # worker-safe table_name (compute_table_name). Reading the # @primary_key / @table_name class ivars raises # Ractor::IsolationError in workers, and the boot-time descendant # snapshot can miss lazy-loaded models, so compute it on demand. # `id` / `to_param` depend on primary_key, so a nil here breaks # URL helpers and record inspection in workers. begin pk = connection_pool.schema_cache.primary_keys(table_name) return pk if pk.is_a?(::String) rescue StandardError nil end nil end end def composite_primary_key? key = :"ractor_rails_shim_pk_\#{name || object_id}" v = RactorRailsShim.storage[key] return v.is_a?(::Array) if RactorRailsShim.storage.key?(key) if Ractor.main? reset_primary_key if PRIMARY_KEY_NOT_SET.equal?(@primary_key) @primary_key.is_a?(::Array) else pk = RactorRailsShim::AR_PRIMARY_KEYS_SHAREABLE[name] pk.is_a?(::Array) end end RUBY end |
._install_activerecord_query_constraints_patch ⇒ Object
Patch Persistence::ClassMethods#query_constraints_list and #has_query_constraints?
to route the lazy @query_constraints_list class ivar through IES.
query_constraints_list does @query_constraints_list ||= <computation>
— the class ivar write fails from a non-main Ractor. Called during
Post.first -> ordered_relation -> _order_columns.
1545 1546 1547 1548 1549 1550 1551 1552 1553 1554 1555 1556 1557 1558 1559 1560 1561 1562 1563 1564 1565 1566 1567 1568 1569 1570 1571 1572 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 1545 def _install_activerecord_query_constraints_patch return if @ar_query_constraints_patched @ar_query_constraints_patched = true _register_patch :activerecord_query_constraints, "8.1" return unless defined?(::ActiveRecord::Persistence::ClassMethods) ::ActiveRecord::Persistence::ClassMethods.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def query_constraints_list key = :"ractor_rails_shim_qcl_\#{name || object_id}" v = RactorRailsShim.storage[key] return v if RactorRailsShim.storage.key?(key) result = if base_class? || primary_key != base_class.primary_key primary_key if primary_key.is_a?(::Array) else base_class.query_constraints_list end RactorRailsShim.storage[key] = result result end def has_query_constraints? key = :"ractor_rails_shim_qcl_\#{name || object_id}" v = RactorRailsShim.storage[key] return !v.nil? if RactorRailsShim.storage.key?(key) result = query_constraints_list !result.nil? end RUBY end |
._install_activerecord_query_logs_patch ⇒ Object
Patch ActiveRecord::QueryLogs#tag_content. It reads the @handlers and
@formatter class ivars (populated from config.active_record.query_log_tags
during boot) on every SQL statement, so a worker Ractor raises
Ractor::IsolationError. cached_comment is a thread_mattr_accessor (already
Ractor-safe), so only the handlers/formatter need handling. Capture a
shareable snapshot in main (snapshot_query_logs!, post-boot) and have
workers build the comment from it — query-log tags then work in workers
exactly as in dev's main Ractor.
2088 2089 2090 2091 2092 2093 2094 2095 2096 2097 2098 2099 2100 2101 2102 2103 2104 2105 2106 2107 2108 2109 2110 2111 2112 2113 2114 2115 2116 2117 2118 2119 2120 2121 2122 2123 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 2088 def _install_activerecord_query_logs_patch return if @query_logs_patched @query_logs_patched = true _register_patch :query_logs, "8.1" return unless defined?(::ActiveRecord::QueryLogs) ql = ::ActiveRecord::QueryLogs # tag_content is defined as a SINGLETON method on ActiveRecord::QueryLogs # (not an instance method), so alias the singleton method (not an # instance one) and fall back to it for the main Ractor / when the # snapshot is unavailable. ql.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 alias_method :__ractors_original_tag_content, :tag_content def tag_content(connection) return __ractors_original_tag_content(connection) if Ractor.main? snap = ::RactorRailsShim::QUERY_LOGS_SNAPSHOT return __ractors_original_tag_content(connection) unless snap format = snap[:format] formatter = case format when :sqlcommenter then ::ActiveRecord::QueryLogs::SQLCommenter else ::ActiveRecord::QueryLogs::LegacyFormatter end return nil unless formatter context = ActiveSupport::ExecutionContext.to_h context[:connection] ||= connection pairs = snap[:handlers].filter_map do |(key, kind, data)| val = case kind when :get_key then context[key] when :identity then data else nil end formatter.format(key, val) unless val.nil? end formatter.join(pairs) end RUBY end |
._install_activerecord_query_transformers_patch ⇒ Object
Patch ActiveRecord.query_transformers to route through IES with a
shareable fallback. query_transformers is a singleton_class .attr_accessor (a class instance variable on the ActiveRecord module)
holding an Array of transformer objects (e.g. ActiveRecord::QueryLogs).
DatabaseStatements#preprocess_query reads it on every query:
ActiveRecord.query_transformers.each { |t| t.call(sql, self) }.
Class instance variables are per-Ractor, so a worker's @query_transformers
is nil (set in main at boot via self.query_transformers = [], then
<< QueryLogs in the railtie). The transformer objects themselves ARE
shareable (they're Classes/modules), so we deep-freeze the Array in main
and expose it as a shareable constant the worker reads. Same pattern as
_install_activerecord_db_config_handlers_patch.
1430 1431 1432 1433 1434 1435 1436 1437 1438 1439 1440 1441 1442 1443 1444 1445 1446 1447 1448 1449 1450 1451 1452 1453 1454 1455 1456 1457 1458 1459 1460 1461 1462 1463 1464 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 1430 def _install_activerecord_query_transformers_patch return if @ar_query_transformers_patched @ar_query_transformers_patched = true _register_patch :activerecord_query_transformers, "8.1" return unless defined?(::ActiveRecord) if Ractor.main? begin transformers = ::ActiveRecord.query_transformers transformers.each { |t| _swallow("make ar query transformer shareable") { Ractor.make_shareable(t) } } shareable = Ractor.make_shareable(transformers.dup) _reassign_shareable_const(:AR_QUERY_TRANSFORMERS_SHAREABLE, shareable) rescue StandardError => e # best-effort end end key = :ractor_rails_shim_ar_query_transformers key_str = key.inspect ::ActiveRecord.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def query_transformers v = RactorRailsShim.storage[#{key_str}] return v if RactorRailsShim.storage.key?(#{key_str}) if Ractor.main? @query_transformers else RactorRailsShim::AR_QUERY_TRANSFORMERS_SHAREABLE end end def query_transformers=(val) RactorRailsShim.storage[#{key_str}] = val @query_transformers = val if Ractor.main? end RUBY end |
._install_activerecord_quoting_cache_patch ⇒ Object
Patch the adapter quoting caches (QUOTED_COLUMN_NAMES /
QUOTED_TABLE_NAMES) to use per-Ractor storage instead of the
unshareable Concurrent::Map constants.
Each adapter (SQLite3, MySQL, PostgreSQL) defines these as
Concurrent::Map.new constants in its Quoting module, and the
quote_column_name / quote_table_name class methods lazily populate
them via MAP[name] ||= <quoting_logic>.freeze. Concurrent::Map is
intrinsically unshareable (no #freeze), so a worker Ractor cannot
access the constant at all (Ractor::IsolationError: "can not access
non-shareable objects in constant ..."). This is the fourth wall a worker
hits: during Post.count -> Arel traversal -> quote_table_name ->
QUOTED_TABLE_NAMES[name] ||= ....
Fix: redefine quote_column_name / quote_table_name on each adapter's
Quoting::ClassMethods module to use a per-Ractor Hash cache (stored in
IES, keyed by the adapter class name). Each Ractor builds its own
mutable cache on first access. The quoting logic is replicated per
adapter (it's simple, stable string manipulation). String-eval'd (no
captured binding), callable from any Ractor.
1719 1720 1721 1722 1723 1724 1725 1726 1727 1728 1729 1730 1731 1732 1733 1734 1735 1736 1737 1738 1739 1740 1741 1742 1743 1744 1745 1746 1747 1748 1749 1750 1751 1752 1753 1754 1755 1756 1757 1758 1759 1760 1761 1762 1763 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 1719 def _install_activerecord_quoting_cache_patch return if @ar_quoting_patched @ar_quoting_patched = true _register_patch :activerecord_quoting_cache, "8.1" # [module_path, column_logic, table_logic] per adapter. adapters = [ ["ActiveRecord::ConnectionAdapters::SQLite3::Quoting", %q{%Q("#{name.to_s.gsub('"', '""')}").freeze}, %q{%Q("#{name.to_s.gsub('"', '""').gsub(".", "\".\"")}").freeze}], ["ActiveRecord::ConnectionAdapters::MySQL::Quoting", %q{"`#{name.to_s.gsub('`', '``')}`".freeze}, %q{"`#{name.to_s.gsub('`', '``').gsub(".", "`.`")}`".freeze}], ["ActiveRecord::ConnectionAdapters::PostgreSQL::Quoting", %q{::PG::Connection.quote_ident(name.to_s).freeze}, %q{::ActiveRecord::ConnectionAdapters::PostgreSQL::Utils.extract_schema_qualified_name(name.to_s).quoted.freeze}], ] adapters.each do |mod_path, column_logic, table_logic| mod = begin mod_path.split("::").inject(Object) { |ns, n| ns.const_get(n, false) } rescue StandardError nil end next unless mod klass_mod = mod.const_get(:ClassMethods, false) rescue next next unless klass_mod.method_defined?(:quote_column_name) klass_mod.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def quote_column_name(name) key = :"ractor_rails_shim_quoted_cols_\#{self.name}" cache = RactorRailsShim.storage[key] cache ||= (RactorRailsShim.storage[key] = {}) cache[name] ||= (#{column_logic}) end def quote_table_name(name) key = :"ractor_rails_shim_quoted_tables_\#{self.name}" cache = RactorRailsShim.storage[key] cache ||= (RactorRailsShim.storage[key] = {}) cache[name] ||= (#{table_logic}) end RUBY end end |
._install_activerecord_reaper_patch ⇒ Object
Patch ConnectionPool::Reaper#run to no-op in non-main Ractors.
ConnectionPool#initialize (connection_pool.rb:307) calls @reaper.run,
which calls Reaper.register_pool (a class method). register_pool
reads/writes the Reaper class's instance variables (@mutex, @pools,
background reaper thread. Class instance variables are off-limits to
non-main Ractors (Ractor::IsolationError), so this is the second wall a
worker hits during establish_connection -> ConnectionPool.new.
The reaper is a background maintenance thread that periodically reaps dead-thread connections, flushes idle connections, and keepalives stale ones. In a worker Ractor this is neither safe (can't share the reaper thread or its class-ivar registry across Ractors) nor essential: each Ractor owns its own connection pool, and when the Ractor exits its pool is garbage-collected with it. Connection health for long-lived workers can be addressed later with a per-Ractor reaper if needed; for now, no-op'ing registration unblocks connection establishment.
register_pool is only called from Reaper#run, so patching run to
return early in non-main Ractors fully prevents the class-ivar access and
the thread spawn. The pool itself still functions normally.
1639 1640 1641 1642 1643 1644 1645 1646 1647 1648 1649 1650 1651 1652 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 1639 def _install_activerecord_reaper_patch return if @ar_reaper_patched @ar_reaper_patched = true _register_patch :activerecord_reaper, "8.1" return unless defined?(::ActiveRecord::ConnectionAdapters::ConnectionPool::Reaper) ::ActiveRecord::ConnectionAdapters::ConnectionPool::Reaper.class_eval <<-RUBY, __FILE__, __LINE__ + 1 def run return unless frequency && frequency > 0 return unless Ractor.main? self.class.register_pool(pool, frequency) end RUBY end |
._install_activerecord_reflection_patch ⇒ Object
291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 |
# File 'lib/ractor_rails_shim/patches/activerecord_reflection.rb', line 291 def _install_activerecord_reflection_patch return if @ar_reflection_patched @ar_reflection_patched = true # Apply now if AR is already loaded, otherwise hook active_record's load # (before eager-load builds the app's reflections). Never force a # `require "active_record"` here — that would double-load AR under # `bundle exec` and re-define constants after the shim froze them. if defined?(::ActiveRecord::Reflection::AbstractReflection) _apply_activerecord_reflection_patch else ActiveSupport.on_load(:active_record) do RactorRailsShim.__send__(:_apply_activerecord_reflection_patch) end end end |
._install_activerecord_relation_delegate_cache_patch ⇒ Object
Blockers 3: ActiveRecord model classes cache relation-delegate classes
in the @relation_delegate_cache class instance variable (set in
DelegateCache#initialize_relation_delegate_cache,
activerecord/relation/delegation.rb:31-44). The cache is a plain (mutable)
Hash mapping each delegated class (ActiveRecord::Relation, etc.) to an
anonymous delegate Class. From a worker Ractor, reading the class ivar
raises Ractor::IsolationError ("can not get unshareable values from
instance variables of classes/modules from non-main Ractors
(@relation_delegate_cache from Post)") — even a plain Post.page(1).
The delegate Classes themselves ARE shareable (verified: Ractor.shareable?(Post::ActiveRecord_Relation) == true). Only the enclosing Hash is mutable (unshareable). So we deep-freeze the cache (Ractor.make_shareable) in the main Ractor at prepare/make-shareable time. A class ivar whose value is shareable is readable from a worker Ractor (unlike class variables, which always raise). Freezing is safe: the cache is populated once per class at load time and never mutated afterwards (each relation-delegate Class is const_set as a private constant on the model class).
298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 298 def _install_activerecord_relation_delegate_cache_patch return if @ar_rdc_patched @ar_rdc_patched = true _register_patch :activerecord_relation_delegate_cache, "8.1" return unless defined?(::ActiveRecord::Base) mod = ::ActiveRecord::Delegation::DelegateCache mod.module_eval do # The default implementation reads the delegate class from the # `@relation_delegate_cache` *class instance variable* — an unshareable # Hash populated lazily on the model class. From a worker Ractor that # value is unreadable (Ractor::IsolationError: "can not get unshareable # values from instance variables ... (@relation_delegate_cache from # Post)"). `initialize_relation_delegate_cache` ALSO const_sets each # delegate class onto the model (e.g. `Post::ActiveRecord_Relation`), # and that constant is shareable. So read the delegate class via the # constant instead of the class ivar — Ractor-safe with no per-worker # rebuild. def relation_delegate_class(klass) const_get(klass.name.gsub("::", "_")) end # Keep building + const_setting the delegate classes (shareable), but # stop stashing them in the unshareable `@relation_delegate_cache` ivar. # The ivar is left a frozen empty Hash so any (now-unused) reader of it # is still Ractor-safe. def initialize_relation_delegate_cache @relation_delegate_cache = {}.freeze ::ActiveRecord::Delegation.delegated_classes.each do |k| delegate = Class.new(k) { include ::ActiveRecord::Delegation::ClassSpecificRelation } include_relation_methods(delegate) mangled_name = k.name.gsub("::", "_") const_set mangled_name, delegate private_constant mangled_name end end end _share_relation_delegate_caches! if Ractor.main? end |
._install_activerecord_scope_patch ⇒ Object
Patch ActiveRecord::Scoping::Named::ClassMethods#scope to use string
eval instead of define_method. Rails defines scope methods via
singleton_class.define_method(name) { |*args| scope = all._exec_scope(*args, &body) ... }.
The block captures the body Proc (a lambda like -> { order(created_at: :desc) })
from the main Ractor. Calling that block from a worker Ractor raises
"defined with an un-shareable Proc in a different Ractor".
The body lambda itself CANNOT be made shareable (Proc#self is always the enclosing scope, which is the main Ractor). Fix: use Proc#source_location to read the body's source code at prepare time (main Ractor), store it in a shareable constant, and define the scope method via string eval that eval's the stored source code. This avoids ever calling the original lambda from a worker.
2281 2282 2283 2284 2285 2286 2287 2288 2289 2290 2291 2292 2293 2294 2295 2296 2297 2298 2299 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 2281 def _install_activerecord_scope_patch return if @ar_scope_patched @ar_scope_patched = true _register_patch :activerecord_scope, "8.1" # The scope macro MUST be patched BEFORE the app's models are eager # loaded, so `scope :recent, -> { ... }` defines a worker-safe method # (string-eval'd body) instead of Rails' un-shareable define_method. # Apply now if AR is already loaded, otherwise hook active_record's load # (which fires during framework boot, before eager-load). Never force a # `require "active_record"` here — that would double-load AR under # `bundle exec` and re-define constants after the shim froze them. if defined?(::ActiveRecord::Scoping::Named::ClassMethods) _apply_activerecord_scope_patch else ActiveSupport.on_load(:active_record) do RactorRailsShim.__send__(:_apply_activerecord_scope_patch) end end end |
._install_activerecord_serialize_cast_value_patch ⇒ Object
Patch ActiveModel::Type::SerializeCastValue::ClassMethods #serialize_cast_value_compatible? to route the lazy class instance variable @serialize_cast_value_compatible through IES.
This method lazily caches a boolean: return @x if defined?(@x); @x = <computation>. It's called during type-map initialization (every
adapter's initialize_type_map creates type objects whose constructors
eagerly call this to precompute the value). The class instance variable
write fails from a non-main Ractor (Ractor::IsolationError: "can not set
instance variables of classes/modules by non-main Ractors").
Fix: route through IES so each Ractor computes + caches its own value.
The computation is deterministic (compares ancestor positions of two
methods), so per-Ractor recomputation yields the same result. Each
including class gets its own IES key (keyed by self.name). String-eval'd
(no captured binding), callable from any Ractor.
1898 1899 1900 1901 1902 1903 1904 1905 1906 1907 1908 1909 1910 1911 1912 1913 1914 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 1898 def _install_activerecord_serialize_cast_value_patch return if @ar_serialize_cast_patched @ar_serialize_cast_patched = true _register_patch :activerecord_serialize_cast_value, "8.1" return unless defined?(::ActiveModel::Type::SerializeCastValue) ::ActiveModel::Type::SerializeCastValue::ClassMethods.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def serialize_cast_value_compatible? key = :"ractor_rails_shim_scv_\#{name || object_id}" v = RactorRailsShim.storage[key] return v if RactorRailsShim.storage.key?(key) result = ancestors.index(instance_method(:serialize_cast_value).owner) <= ancestors.index(instance_method(:serialize).owner) RactorRailsShim.storage[key] = result result end RUBY end |
._install_activerecord_transaction_callbacks_patch ⇒ Object
In the shared :ractor graph, ActiveRecord model classes can end up with a
nil __callbacks (the class_attribute value can't be made shareable when
it holds unshareable callback Procs, so the shim's shareable fallback
returns nil). has_transactional_callbacks? calls the generated
_rollback_callbacks / _commit_callbacks / _before_commit_callbacks
readers, which do __callbacks[:kind] directly — bypassing the
run_callbacks_with_nil_safe guard. With a nil __callbacks that raises
NoMethodError: undefined method '[]' for nil, breaking every
save / update / destroy (they run inside a transaction). Guard it:
a nil / empty callback table means no transactional callbacks.
2251 2252 2253 2254 2255 2256 2257 2258 2259 2260 2261 2262 2263 2264 2265 2266 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 2251 def _install_activerecord_transaction_callbacks_patch return if @activerecord_transaction_callbacks_patched @activerecord_transaction_callbacks_patched = true _register_patch :activerecord_transaction_callbacks, "8.1" return unless defined?(::ActiveRecord::Base) ar = ::ActiveRecord::Base ar.class_eval <<-RUBY, __FILE__, __LINE__ + 1 def has_transactional_callbacks? cb = __callbacks return false unless cb !((cb[:rollback] || []).empty?) || !((cb[:commit] || []).empty?) || !((cb[:before_commit] || []).empty?) end RUBY end |
._install_arel_bind_block_patch ⇒ Object
86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 86 def _install_arel_bind_block_patch return if @arel_bind_block_patched @arel_bind_block_patched = true _register_patch :arel_bind_block, "8.1" # PostgreSQL: proc { |i| "$#{i}" } if defined?(::Arel::Visitors::PostgreSQL) ::Arel::Visitors::PostgreSQL.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def bind_block RactorRailsShim::PgBindBlock end RUBY end # ToSql (base): proc { "?" } if defined?(::Arel::Visitors::ToSql) ::Arel::Visitors::ToSql.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def bind_block RactorRailsShim::SqlBindBlock end RUBY end end |
._install_arel_visitor_dispatch_cache_patch ⇒ Object
Patch Arel::Visitors::Visitor.dispatch_cache to route through IES.
dispatch_cache is a class method that lazily initializes a class
instance variable: @dispatch_cache ||= Hash.new { |hash, klass| ... } .compare_by_identity. The cache maps AST node classes to visit method
symbols (e.g. Arel::Nodes::SelectStatement -> :visit_Arel_Nodes_SelectStatement).
It's read on every Arel traversal (every query) and written on cache-miss
(default proc) and on method-not-found fallback (visit() rescue).
The class instance variable @dispatch_cache can't be read or written by
a non-main Ractor (Ractor::IsolationError). The Hash also has a default
Proc (which is intrinsically unshareable), so the value can't be
frozen+shared. This is the third wall a worker hits during the first
query: Post.count -> adapter creation -> arel_visitor ->
ToSql#initialize -> Visitor#initialize -> get_dispatch_cache ->
self.class.dispatch_cache -> @dispatch_cache ||= ....
Fix: route through per-class IES. Each Ractor builds its own mutable
cache (with its own default proc) on first access. The key includes
self.name so each visitor subclass (ToSql, SQLite3, etc.) gets its own
cache — necessary because the method-not-found fallback
(dispatch[object.class] = dispatch[superklass]) resolves differently per
visitor class. The main ractor's existing @dispatch_cache (if any) is
left orphaned; new visitors created after the patch use the IES cache.
String-eval'd (no captured binding), callable from any Ractor.
1679 1680 1681 1682 1683 1684 1685 1686 1687 1688 1689 1690 1691 1692 1693 1694 1695 1696 1697 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 1679 def _install_arel_visitor_dispatch_cache_patch return if @arel_visitor_patched @arel_visitor_patched = true _register_patch :arel_visitor_dispatch_cache, "8.1" return unless defined?(::Arel::Visitors::Visitor) ::Arel::Visitors::Visitor.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def dispatch_cache key = :"ractor_rails_shim_arel_dispatch_\#{name || object_id}" v = RactorRailsShim.storage[key] return v if v cache = Hash.new do |hash, klass| hash[klass] = :"visit_\#{(klass.name || "").gsub("::", "_")}" end.compare_by_identity RactorRailsShim.storage[key] = cache cache end RUBY end |
._install_as_attribute_types_cache_reset! ⇒ Object
ActiveStorage::Blob#metadata is declared via store :metadata, coder: ActiveRecord::Coders::JSON, which registers a Type::Serialized cast
type through decorate_attributes (a lazily-memoized PendingDecorator).
decorate_attributes only resets @default_attributes, NOT the separately
memoized @attribute_types — so if @attribute_types was primed as a
plain Type::Text/Type::Value by an earlier attribute access during
class load (which happens when the class is freshly autoloaded in a worker
Ractor's empty constant namespace), the decorated (Serialized) type is
never recomputed and write_store_attribute later raises "the column
'metadata' has not been configured as a store". Patch decorate_attributes
to also invalidate @attribute_types so the decorated type is picked up.
400 401 402 403 404 405 406 407 408 409 410 411 412 |
# File 'lib/ractor_rails_shim/patches/active_storage.rb', line 400 def _install_as_attribute_types_cache_reset! return if @_as_attr_types_reset_installed @_as_attr_types_reset_installed = true return unless defined?(::ActiveRecord::Base) ::ActiveRecord::Base.singleton_class.prepend(Module.new do def decorate_attributes(names = nil, &decorator) super instance_variable_set(:@attribute_types, nil) rescue StandardError super end end) end |
._install_caching_key_generator_patch ⇒ Object
Patch ActiveSupport::CachingKeyGenerator#generate_key. Its @cache_keys
ivar is a Concurrent::Map; make_app_shareable! rewrites Concurrent::Map
ivars into FROZEN Hashes (see make_shareable.rb), so a worker Ractor's
@cache_keys[args.join("|")] ||= ... write raises FrozenError. The cache
is pure memoization keyed by (generator, args), so route it through
IsolatedExecutionState (one mutable cache per Ractor). The inner
OpenSSL::Digest lambda patch.
405 406 407 408 409 410 411 412 413 414 415 416 417 |
# File 'lib/ractor_rails_shim/patches/active_support.rb', line 405 def _install_caching_key_generator_patch return if @caching_key_generator_patched @caching_key_generator_patched = true _register_patch :caching_key_generator, "8.1" return unless defined?(::ActiveSupport::CachingKeyGenerator) ::ActiveSupport::CachingKeyGenerator.class_eval <<-RUBY, __FILE__, __LINE__ + 1 def generate_key(*args) store = (RactorRailsShim.storage[:ractor_rails_shim_caching_key_generator] ||= {}) key = "\#{object_id}|\#{args.join("|")}" store.fetch(key) { store[key] = @key_generator.generate_key(*args) } end RUBY end |
._install_callbacks_nil_safe_patch ⇒ Object
Patch ActiveSupport::Inflector::Inflections to not read @en_instance
/ @instance class ivars from a worker Ractor. The inflections instance
holds rules (Arrays/Hashes of Strings) populated at boot; for a frozen
shared app it's read-only. Workers share the main-ractor's inflections
instance via a shareable fallback (made shareable in place). instance
/ instance_or_fallback are called per-request during routing (camelize).
Patch ActiveSupport::Callbacks#run_callbacks to tolerate a nil
__callbacks (the case in worker Ractors whose class_attribute fallback
couldn't be made shareable because callback chains hold frozen,
self-capturing Procs). For a frozen, read-only shared app the boot-time
callbacks (ExecutionContext push/pop, CurrentAttributes clear) already
ran in the main Ractor at boot; worker Ractors don't need to re-run
them per request (CurrentAttributes/ExecutionContext are thread-local,
hence per-Ractor, and start empty in a fresh worker). When __callbacks
is nil, run_callbacks just yields the block — matching the empty-chain
fast path in the original. Moved here from execution_wrapper.rb (it
patches ActiveSupport::Callbacks, not ExecutionWrapper).
49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 |
# File 'lib/ractor_rails_shim/patches/active_support.rb', line 49 def _install_callbacks_nil_safe_patch return if @callbacks_nil_safe_patched @callbacks_nil_safe_patched = true _register_patch :callbacks_nil_safe, "8.1" return unless defined?(::ActiveSupport::Callbacks) ::ActiveSupport::Callbacks.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def run_callbacks_with_nil_safe(kind, type = nil) kind = kind.to_sym strategy = RactorRailsShim.storage_strategy # The generalized callback transport (see RactorRailsShim::Callbacks). # If any registered transport applies to this `kind` and the gate is # open (Ractor mode: __callbacks chain is empty — the worker case; # Thread mode: gate is closed so the real chain runs), delegate replay # to the Registry, which owns the single `yield` and asks each # applicable transport to run its before/after work. This generalizes # the old hardcoded `kind == :process_action || kind == :destroy` to # ANY callback kind that a transport has been registered for. registry = ::RactorRailsShim::Callbacks.registry if registry.applicable(kind).any? callbacks = __callbacks[kind] if __callbacks if strategy.replay_callbacks?(callbacks) return registry.replay(self, kind) { (yield if block_given?) } end end callbacks = __callbacks[kind] if __callbacks if callbacks.nil? || callbacks.empty? yield if block_given? else run_callbacks_without_nil_safe(kind, type) { yield if block_given? } end end alias_method :run_callbacks_without_nil_safe, :run_callbacks alias_method :run_callbacks, :run_callbacks_with_nil_safe RUBY end |
._install_controller_logger_patch ⇒ Object
logger is delegated to config (a class_attribute) which loses its
value when make_app_shareable! deep-freezes the shared graph, so a
worker reads a nil/empty config and logger raises DelegationError
("logger delegated to config, but config is nil"). default_render
(ImplicitRender) calls logger for its debug log, so a no-template
request (e.g. GET /) raises in workers. Fall back to the CLASS-level
ActionController::Base.config.logger — which IS correct in workers
(it reads the frozen shareable class config) — and finally to
Rails.logger. The real delegated logger still wins whenever it is
readable (main, or workers whose config propagated).
363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 |
# File 'lib/ractor_rails_shim/patches/action_controller.rb', line 363 def _install_controller_logger_patch return if @controller_logger_patched @controller_logger_patched = true _register_patch :controller_logger, "8.1" return unless defined?(::ActionController::Base) ::ActionController::Base.prepend(Module.new do def logger super rescue ActiveSupport::DelegationError ::ActionController::Base.config.logger || ::Rails.logger end # `config` is a class_attribute whose value cannot be deep-frozen, so # workers read nil (or a frozen empty default) instead of the real # action_controller config. Several code paths then call # `config.inheritable_copy` / `config.logger` and blow up # (NoMethodError / DelegationError) — including # ActionView::Helpers::ControllerHelper#assign_controller during # template rendering. Fall back to the CLASS-level # `ActionController::Base.config`, which IS correct in workers (it # reads the frozen shareable class config). def config cfg = super cfg || ::ActionController::Base.config rescue NoMethodError, ActiveSupport::DelegationError ::ActionController::Base.config end end) ::ActionController::Base.singleton_class.prepend(Module.new do def logger super rescue ActiveSupport::DelegationError ::ActionController::Base.config.logger || ::Rails.logger end end) end |
._install_controller_params_wrapper_patch ⇒ Object
ParamsWrapper#_wrapper_options is a class_attribute whose default
value (ActionController::ParamsWrapper::Options.from_hash(format: []))
holds a Mutex.new and back-references the controller/model klass, so
it cannot be deep-frozen into the shared graph — its value is nil in a
worker Ractor (see the #__class_attr__wrapper_options boot warning).
ParamsWrapper#_wrapper_formats then calls _wrapper_options.format
and raises NoMethodError: private method `format' called for nil on
every wrapped request (e.g. POST /posts/:id/comments). Fall back to the
class_attribute's own declared default — wrapping disabled — whenever
the real value is unreadable in the worker. The default is built lazily
inside the method (never captured in a closure) so the prepended module
stays Ractor-shareable — the Options object (which owns a Mutex) only
ever lives in the worker that calls it.
413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 |
# File 'lib/ractor_rails_shim/patches/action_controller.rb', line 413 def _install_controller_params_wrapper_patch return if @controller_params_wrapper_patched @controller_params_wrapper_patched = true _register_patch :controller_params_wrapper, "8.1" return unless defined?(::ActionController::Base) && defined?(::ActionController::ParamsWrapper) ::ActionController::Base.prepend(Module.new do def super || ::ActionController::ParamsWrapper::Options.from_hash(format: []) rescue NoMethodError, ActiveSupport::DelegationError ::ActionController::ParamsWrapper::Options.from_hash(format: []) end end) end |
._install_csrf_reset_patch ⇒ Object
In the shared :ractor graph, Devise's engine controllers (e.g.
Devise::SessionsController) end up with a nil csrf_token_storage_strategy
at request time — the value is dropped when make_app_shareable! deep-freezes
the app (RequestForgeryProtection sets it only on ActionController::Base.config,
and the per-controller frozen config copy loses it). A worker then raises
NoMethodError on reset_csrf_token during reset_session (logout /
sign_out). Guard the reset so a missing strategy is a no-op — reset_session
regenerates the session id anyway, discarding the CSRF token.
287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 |
# File 'lib/ractor_rails_shim/patches/action_controller.rb', line 287 def _install_csrf_reset_patch return if @csrf_reset_patched @csrf_reset_patched = true _register_patch :csrf_reset, "8.1" return unless defined?(::ActionController::RequestForgeryProtection) rfp = ::ActionController::RequestForgeryProtection rfp.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def reset_csrf_token(request) # :doc: request.env.delete(CSRF_TOKEN) strat = csrf_token_storage_strategy strat.reset(request) if strat end RUBY # `csrf_token_storage_strategy` and `forgery_protection_strategy` are # both delegated to the controller `config` (a class_attribute). In the # shared :ractor graph the per-controller frozen `config` copy loses # them, so a worker reads nil and CSRF token handling raises # ("undefined method 'fetch' for nil" / "undefined method 'new' for # nil"). Default to the standard SessionStore / Exception strategies so # token ISSUANCE and VALIDATION work in workers. # # NOTE: prepending to RequestForgeryProtection (a module) would NOT # reach the controllers, because ActionController::Base already # *included* it before this patch runs. Prepend to the BASE CLASS so # every controller (incl. Devise subclasses) picks up the default. # The defaults are referenced via their constant paths (not captured # locals) because `def` bodies do not close over enclosing locals. ::ActionController::Base.prepend(Module.new do def csrf_token_storage_strategy super || ::ActionController::RequestForgeryProtection::SessionStore.new end def forgery_protection_strategy super || ::ActionController::RequestForgeryProtection::ProtectionMethods::Exception end # Delegated to `config` (a class_attribute) which loses its value when # make_app_shareable! deep-freezes the shared graph, so a worker reads # nil. With a nil param key, form_authenticity_param reads params[nil] # and CSRF VALIDATION rejects every POST (even with a valid token), # because the token is carried under the real key (:authenticity_token). # Default to the standard param name so validation can find the token. def request_forgery_protection_token super || :authenticity_token end # `allow_forgery_protection` is delegated to `config` (a class_attribute # carrying the full, unshareable action_controller config graph). That # graph cannot be deep-frozen, so the shareable fallback built at # prepare_for_ractors! falls back to the EMPTY default — and a worker's # view `config` therefore reports `allow_forgery_protection = nil`, # making `protect_against_forgery?` false and suppressing CSRF token # issuance (no `<meta name="csrf-token">`, no hidden form field). The # CLASS-level `ActionController::Base.config.allow_forgery_protection` # IS correct in workers (it reads the frozen shareable class config), # so fall back to it when the per-instance/config value is unavailable. # The real value still wins whenever it is readable (main, or workers # whose config propagated), so apps that disable forgery protection # are unaffected. def allow_forgery_protection super || ::ActionController::Base.config.allow_forgery_protection end end) end |
._install_devise_authenticatable_patch ⇒ Object
Devise::Models::Authenticatable::ClassMethods#devise_parameter_filter
memoizes @devise_parameter_filter ||= Devise::ParameterFilter.new(...)
on the MODEL CLASS. A worker Ractor cannot set an instance variable on a
shared class/module, so calling it raises "can not set instance variables
of classes/modules by non-main Ractors". Route the memoized filter through
IsolatedExecutionState (per-Ractor), keyed by the (shared, stable) class
object_id. case_insensitive_keys / strip_whitespace_keys are
class_attribute values the shim already routes through IES, so they read
fine in a worker.
85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 |
# File 'lib/ractor_rails_shim/patches/devise.rb', line 85 def _install_devise_authenticatable_patch return if @devise_authenticatable_patched @devise_authenticatable_patched = true _register_patch :devise_authenticatable, "5.0" return unless defined?(::Devise::Models::Authenticatable::ClassMethods) ::Devise::Models::Authenticatable::ClassMethods.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def devise_parameter_filter key = :"ractor_rails_shim_devise_param_filter_\#{object_id}" v = RactorRailsShim.storage[key] return v if RactorRailsShim.storage.key?(key) f = Devise::ParameterFilter.new(case_insensitive_keys, strip_whitespace_keys) RactorRailsShim.storage[key] = f f end RUBY end |
._install_devise_failure_app_patch ⇒ Object
Devise::FailureApp.call memoizes its Rack endpoint in a class-level ivar
(@respond ||= action(:respond)). A worker Ractor cannot set instance
variables on a shared class/module, so calling it raises "can not set
instance variables of classes/modules by non-main Ractors". Route the
memoized endpoint through IsolatedExecutionState (per-Ractor), so each
worker builds and caches its own copy without mutating the shared class.
48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 |
# File 'lib/ractor_rails_shim/patches/devise.rb', line 48 def _install_devise_failure_app_patch return if @devise_failure_app_patched @devise_failure_app_patched = true _register_patch :devise_failure_app, "5.0" return unless defined?(::Devise::FailureApp) ::Devise::FailureApp.singleton_class.module_eval <<-'RUBY', __FILE__, __LINE__ + 1 def call(env) respond = RactorRailsShim.storage[:ractor_rails_shim_devise_failure_respond] ||= action(:respond) respond.call(env) end RUBY # Devise::FailureApp#relative_url_root reads `config.action_controller # .try(:relative_url_root)`. `config.action_controller` is a # Rails::Railtie::Configuration whose `relative_url_root` is NOT a real # method, so `try` triggers `method_missing`, which reads the `@@options` # class variable — unreadable from a worker Ractor. `Rails.application # .config.relative_url_root` IS a real method on # Rails::Application::Configuration (no method_missing, no class var), so # use just that; for the common case (no relative root) it returns nil. if defined?(::Devise::FailureApp) ::Devise::FailureApp.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def relative_url_root @relative_url_root ||= Rails.application.config.relative_url_root end RUBY end end |
._install_devise_mailer_patch ⇒ Object
Devise.mailer reads @@mailer_ref (a class variable) which raises
IsolationError from a worker Ractor. The @@mailer_ref holds a
Devise::Getter instance (which is shareable — it only holds a String
class name). Capture it at prepare time and have workers read the
captured shareable copy instead of the cvar.
Also patch Devise.parent_mailer and Devise.mailer_sender which read
@@parent_mailer / @@mailer_sender cvars — these are read when
building mailer messages.
110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 |
# File 'lib/ractor_rails_shim/patches/devise.rb', line 110 def _install_devise_mailer_patch return if @devise_mailer_patched @devise_mailer_patched = true _register_patch :devise_mailer, "5.0" return unless defined?(::Devise) # Capture the Getter instance (it holds a String — shareable). mailer_ref = nil begin mailer_ref = ::Devise.class_variable_get(:@@mailer_ref) rescue StandardError nil end if mailer_ref RactorRailsShim.const_set(:SHAREABLE_DEVISE_MAILER_REF, Ractor.make_shareable(mailer_ref)) end ::Devise.singleton_class.module_eval <<-'RUBY', __FILE__, __LINE__ + 1 def mailer if ::Ractor.main? @@mailer_ref.get else ref = ::RactorRailsShim::SHAREABLE_DEVISE_MAILER_REF ref ? ref.get : ::Devise::Mailer end end RUBY end |
._install_devise_url_helpers_patch ⇒ Object
140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 |
# File 'lib/ractor_rails_shim/patches/devise.rb', line 140 def _install_devise_url_helpers_patch return if @devise_url_helpers_patched @devise_url_helpers_patched = true _register_patch :devise_url_helpers, "5.0" # Generate DeviseMappingSnapshot predicates from Devise::MODULES now that # Devise is loaded. Replaces the hardcoded fallback list with the actual # module set (incl. any third-party gems added at boot). No-op if # Devise::MODULES isn't defined. DeviseMappingSnapshot.generate_predicates_from_devise_modules! return unless defined?(::Devise::Controllers::UrlHelpers) mod = ::Devise::Controllers::UrlHelpers # Patch Devise.mappings FIRST (before the snapshot below) so every # subsequent read — including the snapshot — does NOT trigger a route # reload. Routes are fully drawn by Rails.application.initialize! before # prepare_for_ractors! runs, so @@mappings is already populated in main; # workers read the shareable snapshot instead. The ORIGINAL # Devise.mappings calls reload_routes_unless_loaded, which during # prepare collapses the RouteSet to a single railtie route (rails/info) # that then gets frozen into the shared app graph — breaking routing for # every worker Ractor. if defined?(::Devise) && ::Devise.respond_to?(:mappings) ::Devise.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def mappings return RactorRailsShim::DEVISE_MAPPINGS if !Ractor.main? && RactorRailsShim.const_defined?(:DEVISE_MAPPINGS) ::Devise.class_variable_get(:@@mappings) end RUBY end # Capture a shareable snapshot of Devise.mappings in MAIN (after routes # are drawn). The real Mapping objects hold an unshareable failure-app # lambda and a default-proc Hash, so we can't share them directly; build # a DeviseMappingSnapshot per scope (see make_shareable.rb). Workers read # this via the patched Devise.mappings reader. if Ractor.main? && defined?(::Devise) begin h = ::Devise.mappings snap = {} h.each { |scope, mapping| snap[scope] = _devise_mapping_snapshot(mapping) } snap = _swallow("make devise mappings shareable") { Ractor.make_shareable(snap) } const_set(:DEVISE_MAPPINGS, snap) if snap rescue StandardError nil end end # Redefine each generated helper via string eval (no captured binding). # Replicate the EXACT body of Devise::Controllers::UrlHelpers.generate_helpers! # (lib/devise/controllers/url_helpers.rb): the helper does NOT call the # alias method on the context — it reconstructs the REAL route helper name # from `action` + `scope` + `module_name` (e.g. alias `session_path` -> # real `user_session_path`) and sends THAT to the context. Bake in # `action`/`module_name`/`path_or_url` as literals; `scope` is resolved # per-call from the argument, so interpolate it at runtime (\\#{scope}). routes = ::Devise::URL_HELPERS.slice(*(::Devise.mappings.values.map(&:used_helpers).flatten.uniq)) routes.each do |module_name, actions| [:path, :url].each do |path_or_url| actions.each do |action| action_prefix = action ? "#{action}_" : "" method = :"#{action_prefix}#{module_name}_#{path_or_url}" mod.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def #{method}(resource_or_scope, *args) scope = Devise::Mapping.find_scope!(resource_or_scope) router_name = Devise.mappings[scope].router_name context = router_name ? send(router_name) : _devise_route_context context.send("#{action_prefix}\#{scope}_#{module_name}_#{path_or_url}", *args) end RUBY end end end end |
._install_exception_wrapper_patch ⇒ Object
Patch ActionDispatch::ExceptionWrapper instance methods that read @@rescue_responses / @@rescue_templates class variables directly (bypassing the mattr_accessor reader the shim already reroutes through IES). Workers can't read class vars; route through the class method.
61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 |
# File 'lib/ractor_rails_shim/patches/action_dispatch.rb', line 61 def _install_exception_wrapper_patch return if @exception_wrapper_patched @exception_wrapper_patched = true _register_patch :exception_wrapper, "8.1" return unless defined?(::ActionDispatch::ExceptionWrapper) ::ActionDispatch::ExceptionWrapper.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def rescue_template self.class.rescue_templates[exception_class_name] end def status_code ActionDispatch::Response.rack_status_code(self.class.rescue_responses[exception_class_name]) end def rescue_response? self.class.rescue_responses.key?(exception.class.name) end RUBY # Also patch the class method (status_code_for_exception) that reads # @@rescue_responses directly — called by ActionController::Instrumentation # at request time. Route through the mattr reader (which the shim already # reroutes through IES). ::ActionDispatch::ExceptionWrapper.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def status_code_for_exception(class_name) ActionDispatch::Response.rack_status_code(rescue_responses[class_name]) end RUBY end |
._install_execution_context_patch ⇒ Object
239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 |
# File 'lib/ractor_rails_shim/patches/active_support.rb', line 239 def _install_execution_context_patch return if @exec_context_patched @exec_context_patched = true _register_patch :execution_context, "8.1" return unless defined?(::ActiveSupport::ExecutionContext) ec = ::ActiveSupport::ExecutionContext acb_key = :ractor_rails_shim_exec_context_after_change_callbacks nest_key = :ractor_rails_shim_exec_context_nestable acb_key_str = acb_key.inspect nest_key_str = nest_key.inspect ec.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def after_change_callbacks v = RactorRailsShim.storage[#{acb_key_str}] return v if RactorRailsShim.storage.key?(#{acb_key_str}) if Ractor.main? && instance_variable_defined?(:@after_change_callbacks) v = @after_change_callbacks RactorRailsShim.storage[#{acb_key_str}] = v v else arr = [] RactorRailsShim.storage[#{acb_key_str}] = arr arr end end def after_change(&block) after_change_callbacks << block end def nestable v = RactorRailsShim.storage[#{nest_key_str}] return v if RactorRailsShim.storage.key?(#{nest_key_str}) if Ractor.main? && instance_variable_defined?(:@nestable) v = @nestable RactorRailsShim.storage[#{nest_key_str}] = v v else false end end def nestable=(val) RactorRailsShim.storage[#{nest_key_str}] = val @nestable = val if Ractor.main? val end RUBY # Rewrite the methods that read @after_change_callbacks directly. ec.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def set(**options) options.symbolize_keys! keys = options.keys store = record.store previous_context = if block_given? keys.zip(store.values_at(*keys)).to_h end store.merge!(options) after_change_callbacks.each(&:call) if block_given? begin yield ensure store.merge!(previous_context) after_change_callbacks.each(&:call) end end end def []=(key, value) record.store[key.to_sym] = value after_change_callbacks.each(&:call) end RUBY end |
._install_flash_helpers_patch ⇒ Object
Patch the flash-type helper methods (notice, alert, ...) defined by
ActionController::Metal::Flash#add_flash_types via
define_method(type) { request.flash[type] }. That block is compiled
in the MAIN Ractor, so calling it from a worker Ractor raises
"defined with an un-shareable Proc in a different Ractor". Redefine each
flash type as a string-eval'd method (no captured binding) so it is
callable from any Ractor. Called at prepare_for_ractors! time, after
the controllers are loaded and the types are known.
437 438 439 440 441 442 443 444 445 446 447 |
# File 'lib/ractor_rails_shim/patches/action_controller.rb', line 437 def _install_flash_helpers_patch return if @flash_helpers_patched @flash_helpers_patched = true _register_patch :flash_helpers, "8.1" return unless defined?(::ActionController::Base) types = ::ActionController::Base._flash_types rescue [] types.each do |type| ::ActionController::Base.class_eval "def #{type}; request.flash[#{type.inspect}]; end" ::ActionController::Base.send(:private, type) if ::ActionController::Base.private_method_defined?(type) rescue nil end end |
._install_hash_compute_if_absent_patch ⇒ Object
The shim's make_app_shareable! replaces Concurrent::Map instance variables
(which are not Ractor-shareable) with plain Hashes so workers can read
them. But Rails code calls Concurrent::Map#compute_if_absent on these
caches (e.g. ActiveModel::AttributeMethods' attribute_method_patterns_
cache). Plain Hash lacks that method, so we add a compatible definition.
See RactorRailsShim::Patches::HashComputeIfAbsent for the full
semantics contract (nil-storing, per-Hash IES isolation via object_id).
Install the Hash#compute_if_absent patch. Delegates to
RactorRailsShim::Patches::HashComputeIfAbsent.install (extracted
Step 22.1, Issue #22); kept as a facade method so the existing
framework_patch_dispatch auto-discovery (which enumerates
_install_*_patch singleton methods), compute_if_absent_spec.rb,
and storage_spec.rb keep passing. The idempotency flag now lives
on the role object. See Patches::HashComputeIfAbsent for the
contract (mutable vs frozen receiver, nil-storing, per-Hash IES
isolation via object_id).
346 347 348 |
# File 'lib/ractor_rails_shim/patches/core.rb', line 346 def _install_hash_compute_if_absent_patch Patches::HashComputeIfAbsent.install end |
._install_i18n_backend_patch ⇒ Object
Patch I18n::Backend::Simple::Implementation#translations and
#store_translations. The original uses a MUTEX-backed Concurrent::Hash
default block (MUTEX is a non-shareable constant on the module), so a
worker Ractor that builds its own (per the patched I18n::Config#backend)
backend and lazy-loads translations hits "can not access non-shareable
objects in constant ...MUTEX". Worker-local backends are single-threaded
(a Ractor serializes its requests), so drop the mutex and use a plain
Hash. Applied at prepare_for_ractors! time (after the i18n backend class
is loaded).
390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 |
# File 'lib/ractor_rails_shim/patches/i18n.rb', line 390 def _install_i18n_backend_patch return if @i18n_backend_patched @i18n_backend_patched = true _register_patch :i18n_backend, "8.1" return unless defined?(::I18n::Backend::Simple::Implementation) impl = ::I18n::Backend::Simple::Implementation impl.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def translations(do_init: false) init_translations if do_init && !initialized? @translations ||= {} end def store_translations(locale, data, options = {}) if ::I18n.enforce_available_locales && ::I18n.available_locales_initialized? && !::I18n.locale_available?(locale) return data end locale = locale.to_sym translations[locale] ||= {} data = ::I18n::Utils.deep_symbolize_keys(data) unless options.fetch(:skip_symbolize_keys, false) ::I18n::Utils.deep_merge!(translations[locale], data) end RUBY end |
._install_i18n_interpolation_patch ⇒ Object
Patch I18n.interpolate_hash. It reads INTERPOLATION_PATTERNS_CACHE — a constant Hash with a default proc (unshareable) — to fetch the compiled interpolation Regexp. A worker Ractor cannot read that constant, raising "can not access non-shareable objects in constant I18n::INTERPOLATION_PATTERNS_CACHE by non-main ractor". Route the cache through IsolatedExecutionState so each Ractor compiles its own Regexp once.
421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 |
# File 'lib/ractor_rails_shim/patches/i18n.rb', line 421 def _install_i18n_interpolation_patch return if @i18n_interpolation_patched @i18n_interpolation_patched = true _register_patch :i18n_interpolation, "8.1" return unless defined?(::I18n) ::I18n.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def interpolate_hash(string, values) patterns = config.interpolation_patterns cache = (RactorRailsShim.storage[:ractor_rails_shim_i18n_interp_cache] ||= {}) pattern = cache[patterns] ||= ::Regexp.union(patterns) interpolated = false interpolated_string = string.gsub(pattern) do |match| interpolated = true if match == '%%' '%' else key = ($1 || $2 || match.tr("%{}", "")).to_sym value = if values.key?(key) values[key] else config.missing_interpolation_argument_handler.call(key, values, string) end value = value.call(values) if value.respond_to?(:call) $3 ? sprintf("%#{$3}", value) : value end end interpolated ? interpolated_string : string end RUBY end |
._install_i18n_patch ⇒ Object
Patch I18n::Config's class-variable-backed accessors (default_locale,
locale, backend, etc.) to not read @@cvars from a worker Ractor. I18n
defines these manually (@@default_locale ||= :en), not via
cattr_accessor, so the shim's mattr rewrite doesn't catch them. The
values are frozen Symbols / shareable config objects; route the
frequently-read ones (default_locale, locale) through IES with the same
default. Read per-request during view lookup (LookupContext details).
17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 |
# File 'lib/ractor_rails_shim/patches/i18n.rb', line 17 def _install_i18n_patch return if @i18n_patched @i18n_patched = true _register_patch :i18n, "8.1" return unless defined?(::I18n::Config) cfg = ::I18n::Config dl_key = :ractor_rails_shim_i18n_default_locale l_key = :ractor_rails_shim_i18n_locale av_key = :ractor_rails_shim_i18n_available_locales avs_key = :ractor_rails_shim_i18n_available_locales_set dl_key_str = dl_key.inspect l_key_str = l_key.inspect av_key_str = av_key.inspect avs_key_str = avs_key.inspect cfg.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def default_locale v = RactorRailsShim.storage[#{dl_key_str}] return v if RactorRailsShim.storage.key?(#{dl_key_str}) if Ractor.main? && defined?(@@default_locale) cv = @@default_locale RactorRailsShim.storage[#{dl_key_str}] = cv return cv end RactorRailsShim.storage[#{dl_key_str}] = :en :en end def default_locale=(locale) v = locale && locale.to_sym RactorRailsShim.storage[#{dl_key_str}] = v @@default_locale = v if Ractor.main? v end def locale v = RactorRailsShim.storage[#{l_key_str}] return v if RactorRailsShim.storage.key?(#{l_key_str}) default_locale end def locale=(locale) v = locale && locale.to_sym RactorRailsShim.storage[#{l_key_str}] = v v end # available_locales is read per-request during view template lookup # (ActionView::Resolver::PathParser#build_path_regex). The original # reads the @@available_locales class variable, which a worker Ractor # cannot access (Ractor::IsolationError). Route it through IES. In # main we mirror the class var; in a worker we default to [:en] WITHOUT # delegating to backend.available_locales (that path reads the @@backend # / @@load_path class vars, which are also unreadable from a worker). # The template-regex path only needs the list of locale symbols, and # [:en] is the documented I18n default — correct for apps that don't # set config.i18n.available_locales explicitly. def available_locales v = RactorRailsShim.storage[#{av_key_str}] return v if RactorRailsShim.storage.key?(#{av_key_str}) if Ractor.main? if defined?(@@available_locales) && (cv = @@available_locales) RactorRailsShim.storage[#{av_key_str}] = cv return cv end al = backend.available_locales al = al.freeze if al.respond_to?(:freeze) && !al.frozen? RactorRailsShim.storage[#{av_key_str}] = al return al end al = [:en].freeze RactorRailsShim.storage[#{av_key_str}] = al al end def available_locales=(locales) v = Array(locales).map { |l| l.to_sym } v = nil if v.empty? RactorRailsShim.storage[#{av_key_str}] = v @@available_locales = v if Ractor.main? v end def available_locales_set v = RactorRailsShim.storage[#{avs_key_str}] return v if RactorRailsShim.storage.key?(#{avs_key_str}) if Ractor.main? && defined?(@@available_locales_set) && (cv = @@available_locales_set) RactorRailsShim.storage[#{avs_key_str}] = cv return cv end s = available_locales.inject(Set.new) { |set, locale| set << locale.to_s << locale.to_sym } RactorRailsShim.storage[#{avs_key_str}] = s s end def available_locales_initialized? !!(RactorRailsShim.storage[#{av_key_str}]) end # enforce_available_locales is read during every I18n.translate (the # Label/tag translation path in views). The original reads the # @@enforce_available_locales class variable, which a worker Ractor # cannot access (Ractor::IsolationError). Route it through IES; in main # we mirror the class var, in a worker we default to `true` (the # documented I18n default). def enforce_available_locales v = RactorRailsShim.storage[:ractor_rails_shim_i18n_enforce] return v if RactorRailsShim.storage.key?(:ractor_rails_shim_i18n_enforce) if Ractor.main? && defined?(@@enforce_available_locales) cv = @@enforce_available_locales RactorRailsShim.storage[:ractor_rails_shim_i18n_enforce] = cv return cv end RactorRailsShim.storage[:ractor_rails_shim_i18n_enforce] = true true end def enforce_available_locales=(val) v = !!val RactorRailsShim.storage[:ractor_rails_shim_i18n_enforce] = v @@enforce_available_locales = v if Ractor.main? v end # backend reads the @@backend class variable (which a worker Ractor # cannot access). The backend holds the loaded translations. Because the # translation data can contain Procs (e.g. `number.nth.ordinals` in # ActiveSupport's en locale), the whole backend cannot be deep-frozen # and shared. Instead, each worker builds its OWN backend instance (of # the same class as the main backend, so fallbacks etc. are preserved) # and lazy-loads translations from the shareable +load_path+ (see the # patched `load_path`/`load_path=`). The worker-local backend is mutable # (its @interpolations Proc is created in the worker, so it's fine). def backend if Ractor.main? @@backend ||= ::I18n::Backend::Simple.new else key = :ractor_rails_shim_i18n_backend b = RactorRailsShim.storage[key] return b if b cls = (RactorRailsShim.const_defined?(:I18N_BACKEND_CLASS) && RactorRailsShim::I18N_BACKEND_CLASS) || ::I18n::Backend::Simple b = cls.new RactorRailsShim.storage[key] = b b end end def backend=(value) @@backend = value end # load_path reads the @@load_path class variable (unreadable from a # worker). Capture the (shareable) list of translation file paths in # main; workers reload translations from disk via these paths. def load_path v = RactorRailsShim.storage[:ractor_rails_shim_i18n_load_path] return v if RactorRailsShim.storage.key?(:ractor_rails_shim_i18n_load_path) if Ractor.main? lp = (defined?(@@load_path) && @@load_path) || [] lp = lp.dup.freeze if lp.respond_to?(:freeze) && !lp.frozen? RactorRailsShim.storage[:ractor_rails_shim_i18n_load_path] = lp lp else RactorRailsShim.const_defined?(:I18N_LOAD_PATH) ? RactorRailsShim::I18N_LOAD_PATH : [] end end def load_path=(lp) lp = Array(lp) RactorRailsShim.storage[:ractor_rails_shim_i18n_load_path] = lp @@load_path = lp if Ractor.main? lp end # default_separator / exception_handler / missing_interpolation_argument_handler # / interpolation_patterns each read a @@ class variable unreadable from a # worker. Route them through IES; main mirrors the class var, workers use # the documented default (each default is worker-local and shareable-safe: # a String, a fresh ExceptionHandler, a fresh lambda, or the frozen # DEFAULT_INTERPOLATION_PATTERNS constant). def default_separator v = RactorRailsShim.storage[:ractor_rails_shim_i18n_sep] return v if RactorRailsShim.storage.key?(:ractor_rails_shim_i18n_sep) if Ractor.main? cv = defined?(@@default_separator) ? @@default_separator : "." RactorRailsShim.storage[:ractor_rails_shim_i18n_sep] = cv cv else "." end end def default_separator=(separator) RactorRailsShim.storage[:ractor_rails_shim_i18n_sep] = separator @@default_separator = separator if Ractor.main? separator end def exception_handler v = RactorRailsShim.storage[:ractor_rails_shim_i18n_exc] return v if RactorRailsShim.storage.key?(:ractor_rails_shim_i18n_exc) if Ractor.main? cv = defined?(@@exception_handler) ? @@exception_handler : ::I18n::ExceptionHandler.new RactorRailsShim.storage[:ractor_rails_shim_i18n_exc] = cv cv else ::I18n::ExceptionHandler.new end end def exception_handler=(handler) RactorRailsShim.storage[:ractor_rails_shim_i18n_exc] = handler @@exception_handler = handler if Ractor.main? handler end def missing_interpolation_argument_handler v = RactorRailsShim.storage[:ractor_rails_shim_i18n_miss] return v if RactorRailsShim.storage.key?(:ractor_rails_shim_i18n_miss) if Ractor.main? cv = defined?(@@missing_interpolation_argument_handler) ? @@missing_interpolation_argument_handler : lambda do |missing_key, provided_hash, string| raise ::I18n::MissingInterpolationArgument.new(missing_key, provided_hash, string) end RactorRailsShim.storage[:ractor_rails_shim_i18n_miss] = cv cv else lambda do |missing_key, provided_hash, string| raise ::I18n::MissingInterpolationArgument.new(missing_key, provided_hash, string) end end end def missing_interpolation_argument_handler=(handler) RactorRailsShim.storage[:ractor_rails_shim_i18n_miss] = handler @@missing_interpolation_argument_handler = handler if Ractor.main? handler end def interpolation_patterns v = RactorRailsShim.storage[:ractor_rails_shim_i18n_ip] return v if RactorRailsShim.storage.key?(:ractor_rails_shim_i18n_ip) if Ractor.main? cv = defined?(@@interpolation_patterns) ? @@interpolation_patterns : ::I18n::DEFAULT_INTERPOLATION_PATTERNS.dup RactorRailsShim.storage[:ractor_rails_shim_i18n_ip] = cv cv else ::I18n::DEFAULT_INTERPOLATION_PATTERNS end end def interpolation_patterns=(patterns) RactorRailsShim.storage[:ractor_rails_shim_i18n_ip] = patterns @@interpolation_patterns = patterns if Ractor.main? patterns end RUBY # Capture the I18n backend class and shareable load paths in MAIN after # the app has initialized. Workers build their own backend instance of # the captured class and reload translations from the captured load paths # (see `I18n::Config#backend` / `#load_path`). Eager-load the backend # class so the constant is globally defined for worker Ractors. if Ractor.main? && defined?(::I18n) begin # Eager-load I18n classes/constants in main so they are globally # defined for worker Ractors (which cannot autoload). ::I18n::Backend::Simple rescue nil ::I18n::ExceptionHandler rescue nil ::I18n::MissingInterpolationArgument rescue nil ::I18n::DEFAULT_INTERPOLATION_PATTERNS rescue nil backend = ::I18n.backend backend.translate(:en, "") rescue nil backend.available_locales rescue nil const_set(:I18N_BACKEND_CLASS, backend.class) unless RactorRailsShim.const_defined?(:I18N_BACKEND_CLASS) raw_lp = (backend.respond_to?(:instance_variable_get) && backend.instance_variable_get(:@load_path)) || (::I18n.respond_to?(:load_path) && ::I18n.load_path) || [] shareable_lp = Ractor.make_shareable(Array(raw_lp).dup) rescue Array(raw_lp).map(&:to_s).freeze const_set(:I18N_LOAD_PATH, shareable_lp) unless RactorRailsShim.const_defined?(:I18N_LOAD_PATH) rescue StandardError nil end end # Patch I18n.fallbacks (a singleton method on the I18n module) to not # read the @@fallbacks class variable from a worker Ractor. It already # uses Fiber/Thread-local storage with @@fallbacks as the fallback; # route the @@fallbacks read through IES so workers build their own # I18n::Locale::Fallbacks. Called per-request via LookupContext details. if defined?(::I18n) i18n = ::I18n fb_key = :ractor_rails_shim_i18n_fallbacks fb_key_str = fb_key.inspect i18n.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def fallbacks v = RactorRailsShim.storage[#{fb_key_str}] return v if RactorRailsShim.storage.key?(#{fb_key_str}) if Ractor.main? && defined?(@@fallbacks) cv = @@fallbacks if cv RactorRailsShim.storage[#{fb_key_str}] = cv return cv end end built = I18n::Locale::Fallbacks.new RactorRailsShim.storage[#{fb_key_str}] = built built end RUBY # I18n::Locale::Tag.implementation — manual @@implementation ||= Simple. # The value is a module (shareable). Route through IES. if defined?(::I18n::Locale::Tag) tag = ::I18n::Locale::Tag tag_key = :ractor_rails_shim_i18n_tag_implementation tag_key_str = tag_key.inspect tag.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def implementation v = RactorRailsShim.storage[#{tag_key_str}] return v if RactorRailsShim.storage.key?(#{tag_key_str}) if Ractor.main? && defined?(@@implementation) cv = @@implementation RactorRailsShim.storage[#{tag_key_str}] = cv return cv end RactorRailsShim.storage[#{tag_key_str}] = I18n::Locale::Tag::Simple I18n::Locale::Tag::Simple end RUBY end # I18n::Base#normalize_key reads the @@normalized_key_cache class # variable (a double-nested Hash with default procs — unshareable, and # unreadable from a worker). It's a pure performance cache, so route it # through IsolatedExecutionState: each Ractor builds its own nested # cache via I18n.new_double_nested_cache and reads/writes it locally. if defined?(::I18n::Base) nk_key = :ractor_rails_shim_i18n_normalized_key_cache nk_key_str = nk_key.inspect ::I18n::Base.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def normalize_key(key, separator) cache = RactorRailsShim.storage[#{nk_key_str}] cache ||= (RactorRailsShim.storage[#{nk_key_str}] = ::I18n.new_double_nested_cache) cache[separator][key] ||= case key when Array key.flat_map { |k| normalize_key(k, separator) } else keys = key.to_s.split(separator) keys.delete('') keys.map! do |k| case k when /\A[-+]?([1-9]\d*|0)\z/ # integer k.to_i when 'true' true when 'false' false else k.to_sym end end keys end end RUBY end # I18n.reserved_keys_pattern memoizes its compiled regex in a lazy class # ivar (@reserved_keys_pattern) which a worker Ractor cannot write. # Route the cache through IsolatedExecutionState. if defined?(::I18n) rkp_key = :ractor_rails_shim_i18n_reserved_keys_pattern rkp_key_str = rkp_key.inspect ::I18n.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def reserved_keys_pattern v = RactorRailsShim.storage[#{rkp_key_str}] return v if v pat = /(?<!%)%\\{(#{::I18n::RESERVED_KEYS.join("|")})\\}/ RactorRailsShim.storage[#{rkp_key_str}] = pat pat end RUBY end end end |
._install_inflector_patch ⇒ Object
118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 |
# File 'lib/ractor_rails_shim/patches/active_support.rb', line 118 def _install_inflector_patch return if @inflector_patched @inflector_patched = true _register_patch :inflector, "8.1" return unless defined?(::ActiveSupport::Inflector::Inflections) inf = ::ActiveSupport::Inflector::Inflections en_key = :ractor_rails_shim_inflections_en inst_key = :ractor_rails_shim_inflections_instance en_key_str = en_key.inspect inst_key_str = inst_key.inspect inf.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def instance(locale = :en) if locale == :en v = RactorRailsShim.storage[#{en_key_str}] return v if RactorRailsShim.storage.key?(#{en_key_str}) if Ractor.main? existing = instance_variable_get(:@__en_instance__) if instance_variable_defined?(:@__en_instance__) RactorRailsShim.storage[#{en_key_str}] = existing return existing || new.tap { |i| instance_variable_set(:@__en_instance__, i) } end fb = RactorRailsShim::SHAREABLE_FALLBACK[#{en_key_str}] return fb if fb built = new RactorRailsShim.storage[#{en_key_str}] = built built else h = RactorRailsShim.storage[#{inst_key_str}] ||= (Ractor.main? ? (instance_variable_defined?(:@__instance__) ? instance_variable_get(:@__instance__) : Concurrent::Map.new) : Concurrent::Map.new) h[locale] ||= new end end def instance_or_fallback(locale) return instance(locale) if locale == :en h = RactorRailsShim.storage[#{inst_key_str}] if h && h.key?(locale) return h[locale] end if Ractor.main? && instance_variable_defined?(:@__instance__) iv = instance_variable_get(:@__instance__) return iv[locale] if iv && iv.key?(locale) end instance(locale) end RUBY # Register so _build_shareable_fallback! captures the :en inflections # instance (made shareable) for workers. CLASS_ATTRIBUTES << ["ActiveSupport::Inflector::Inflections", :__en_instance__, en_key, nil] # Materialize the :en instance into IES in main so the fallback builder # can read + share it. inf.instance(:en) if Ractor.main? end |
._install_journey_routes_patch ⇒ Object
Make Journey route recognition work under kino :ractor.
The shared app graph (frozen + made shareable by make_app_shareable!)
already carries the routes' ast and the GTG simulator. The simulator,
however, is normally UNshareable because TransitionTable seeds @memos
with a default-Proc Hash (Hash.new { |h,k| h[k] = [] }), and because
its @memos holds the per-route Route objects whose constraint Procs can't
cross Ractor boundaries. The default Proc is the only thing we can fix;
the Route constraint Procs are instead made shareable by make_app_shareable!
when it freezes the whole graph (its proc-replacement pass rewrites them).
So the plan:
1. Patch TransitionTable to use a plain Hash + `add_memo` using `||= []`
(behavior-identical, but shareable once Route memos are frozen).
2. Warm + cache `@ast` / `@simulator` on the live Routes object AFTER
make_app_shareable!'s route precompute (which reloads/resets the
routes) and BEFORE Ractor.make_shareable freezes the graph. Once
frozen into the shared graph, worker Ractors read the cached ivars
via the ORIGINAL Routes#ast/#simulator (no per-worker rebuild — a
rebuild would have to read the frozen Route memos AND reuse several
non-shareable class constants, which is fragile).
We deliberately do NOT override ast/simulator: the original methods read the cached ivars, which is exactly what workers need.
566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 |
# File 'lib/ractor_rails_shim/patches/action_dispatch.rb', line 566 def _install_journey_routes_patch return if @journey_routes_patched @journey_routes_patched = true _register_patch :journey_routes, "8.1" return unless defined?(::ActionDispatch::Journey::Routes) # Patch TransitionTable to drop its default-Proc @memos. Must run before # the simulator is warmed (in _warm_journey_routes!, called from # make_app_shareable! after the route precompute). if defined?(::ActionDispatch::Journey::GTG::TransitionTable) tt = ::ActionDispatch::Journey::GTG::TransitionTable tt.class_eval <<-RUBY, __FILE__, __LINE__ + 1 def initialize @stdparam_states = {} @regexp_states = {} @string_states = {} @accepting = {} @memos = {} end def add_memo(idx, memo) (@memos[idx] ||= []) << memo end RUBY end # Make Routes#ast / #simulator tolerant of a FROZEN receiver. The shim # warms + caches these ivars on the live (unfrozen) graph before freezing, # but if the cache is missing on the frozen shared object (e.g. routes # were re-drawn after warming, or warming was skipped), the original # `@simulator ||= build` raise FrozenError in a worker Ractor. When frozen, # build and RETURN the value without memoizing — the build is read-only # over the frozen route nodes, so it's safe and stays worker-local. routes = ::ActionDispatch::Journey::Routes routes.class_eval <<-RUBY, __FILE__, __LINE__ + 1 def ast return @ast if defined?(@ast) && @ast built = ::ActionDispatch::Journey::Nodes::Or.new(anchored_routes.map(&:ast)) frozen? ? built : (@ast ||= built) end def simulator return @simulator if defined?(@simulator) && @simulator gtg = ::ActionDispatch::Journey::GTG::Builder.new(ast).transition_table built = ::ActionDispatch::Journey::GTG::Simulator.new(gtg) frozen? ? built : (@simulator ||= built) end RUBY # Path::Pattern memoizes several ivars via `||=` (@re, @offsets, # @required_names, @optional_names, @requirements_for_missing_keys_check). # On a frozen shared graph the `||=` assignment raises FrozenError in a # worker Ractor. Make them frozen-tolerant: return the cached value if # present, otherwise build and RETURN it without memoizing (the build is # read-only over the frozen ast/requirements). This keeps workers correct # even if warming skipped a pattern. if defined?(::ActionDispatch::Journey::Path::Pattern) ::ActionDispatch::Journey::Path::Pattern.class_eval <<-RUBY, __FILE__, __LINE__ + 1 def required_names return @required_names if defined?(@required_names) && @required_names built = names - optional_names frozen? ? built : (@required_names ||= built) end def optional_names return @optional_names if defined?(@optional_names) && @optional_names built = spec.find_all(&:group?).flat_map { |g| g.find_all(&:symbol?) }.map(&:name).uniq frozen? ? built : (@optional_names ||= built) end def to_regexp return @re if defined?(@re) && @re built = regexp_visitor.new(@separators, @requirements).accept(spec) frozen? ? built : (@re ||= built) end def requirements_for_missing_keys_check return @requirements_for_missing_keys_check if defined?(@requirements_for_missing_keys_check) && @requirements_for_missing_keys_check built = requirements.transform_values { |regex| /\A#\{regex\}\Z/ } frozen? ? built : (@requirements_for_missing_keys_check ||= built) end def offsets return @offsets if defined?(@offsets) && @offsets built = begin offs = [0] spec.find_all(&:symbol?).each do |node| node = node.to_sym if @requirements.key?(node) re = /#\{Regexp.union(@requirements[node])\}|/ offs.push((re.match("").length - 1) + offs.last) else offs << offs.last end end offs end frozen? ? built : (@offsets ||= built) end RUBY end end |
._install_json_encoding_patch ⇒ Object
Patch ActiveSupport::JSON::Encoding. The module memoizes two encoders in
class ivars (@encoder_without_options / @encoder_without_escape) inside
json_encoder=, and exposes json_encoder as a attr_reader (so it too
reads the @json_encoder class ivar). A worker Ractor cannot read any of
these module ivars, raising Ractor::IsolationError ("can not get
unshareable values from instance variables of classes/modules from
non-main Ractors"). Capture the encoder CLASS in main (on assignment) into
a shareable constant, then build a per-Ractor encoder instance via
IsolatedExecutionState instead of reading the module ivars.
496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 |
# File 'lib/ractor_rails_shim/patches/active_support.rb', line 496 def _install_json_encoding_patch return if @json_encoding_patched @json_encoding_patched = true _register_patch :json_encoding, "8.1" return unless defined?(::ActiveSupport::JSON::Encoding) enc = ::ActiveSupport::JSON::Encoding ec_key = :ractor_rails_shim_json_encoder ec_key_str = ec_key.inspect ecn_key = :ractor_rails_shim_json_encoder_no_escape ecn_key_str = ecn_key.inspect enc.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def json_encoder=(encoder) RactorRailsShim.const_set(:JSON_ENCODER_CLASS, encoder) if Ractor.main? && defined?(RactorRailsShim) @json_encoder = encoder if Ractor.main? encoder end def json_encoder RactorRailsShim::JSON_ENCODER_CLASS end def encode_without_options(value) encoder = RactorRailsShim.storage[#{ec_key_str}] encoder ||= (RactorRailsShim.storage[#{ec_key_str}] = RactorRailsShim::JSON_ENCODER_CLASS.new) encoder.encode(value) end def encode_without_escape(value) encoder = RactorRailsShim.storage[#{ecn_key_str}] encoder ||= (RactorRailsShim.storage[#{ecn_key_str}] = RactorRailsShim::JSON_ENCODER_CLASS.new(escape: false)) encoder.encode(value) end RUBY # The JSON encoding constants (HTML_ENTITIES_REGEX etc.) live on # ActiveSupport::JSON::Encoding but are NOT Ractor-shareable in Ruby 4.0 # (Regexp.union / frozen-string Hash return false for Ractor.shareable?). # Deep-freeze + replace them here (in main, during prepare_for_ractors!) # so worker Ractors can read them when the encoder escapes HTML. Belt and # suspenders alongside the SHAREABLE_CONSTANTS registration. if Ractor.main? %w[ESCAPED_CHARS HTML_ENTITIES_REGEX FULL_ESCAPE_REGEX JS_SEPARATORS_REGEX].each do |name| next unless enc.const_defined?(name, false) v = enc.const_get(name, false) unless Ractor.shareable?(v) begin enc.send(:remove_const, name) if enc.const_defined?(name, false) enc.const_set(name, Ractor.make_shareable(v)) rescue StandardError nil end end end end # Make sure the constant exists on RactorRailsShim so worker references # resolve. It is set on the first `json_encoder=` call during init; seed a # default here so even a direct call before init is safe. unless RactorRailsShim.const_defined?(:JSON_ENCODER_CLASS) RactorRailsShim.const_set(:JSON_ENCODER_CLASS, ::ActiveSupport::JSON::Encoding::JSONGemEncoder) end end |
._install_json_renderer_patch ⇒ Object
render json:, render xml:, and render js: are dispatched to
_render_with_renderer_json / _render_with_renderer_xml /
_render_with_renderer_js — methods Rails defines via
ActionController::Renderers.add(key, &block) →
define_method(_render_with_renderer_method_name(key), &block) at boot
(action_controller/metal/renderers.rb). The block is compiled in the
MAIN Ractor, so calling it from a worker Ractor raises "defined with an
un-shareable Proc in a different Ractor" (the same failure the
StatsController works around with stdlib JSON.generate). Redefine each
default renderer as a string-eval'd def (no captured binding) so it is
callable from any Ractor. The bodies replicate Rails' defaults exactly
(to_json/to_xml + content_type + response_body), referencing only the
receiver (self) and shareable constants (Mime, Mime,
Mime) — no captured locals.
463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 |
# File 'lib/ractor_rails_shim/patches/action_controller.rb', line 463 def _install_json_renderer_patch return if @json_renderer_patched @json_renderer_patched = true _register_patch :json_renderer, "8.1" return unless defined?(::ActionController::Renderers) ::ActionController::Renderers.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def _render_with_renderer_json(json, options) json = json.to_json(options) unless json.kind_of?(String) json = "\#{options[:callback]}(\#{json})" if options[:callback] self.content_type = Mime[:json] self.response_body = json end def _render_with_renderer_xml(xml, options) xml = xml.to_xml(options) unless xml.kind_of?(String) self.content_type = Mime[:xml] self.response_body = xml end def _render_with_renderer_js(js, options) self.content_type = Mime[:js] self.response_body = js.respond_to?(:to_js) ? js.to_js : js end RUBY end |
._install_kaminari_config_patch ⇒ Object
21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 |
# File 'lib/ractor_rails_shim/patches/kaminari.rb', line 21 def _install_kaminari_config_patch return if @kaminari_patched @kaminari_patched = true _register_patch :kaminari_config, "8.1" return unless defined?(::Kaminari) # In the main ractor, capture the config object and make it shareable. shareable_config = nil if Ractor.main? begin cfg = ::Kaminari.instance_variable_get(:@_config) rescue nil if cfg begin Ractor.make_shareable(cfg) shareable_config = cfg rescue StandardError => e # If the config can't be made shareable (unlikely — it's all # integers/symbols/nil), build a fresh one with defaults. shareable_config = Ractor.make_shareable(::Kaminari::Config.new) end else shareable_config = Ractor.make_shareable(::Kaminari::Config.new) end rescue StandardError => e # Best-effort end end shareable_config ||= Ractor.make_shareable(::Kaminari::Config.new) rescue nil # Store the shareable config as a constant so workers can read it. if shareable_config _reassign_shareable_const(:KAMINARI_SHAREABLE_CONFIG, shareable_config) end k_key = :ractor_rails_shim_kaminari_config k_key_str = k_key.inspect ::Kaminari.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def config v = RactorRailsShim.storage[#{k_key_str}] return v if RactorRailsShim.storage.key?(#{k_key_str}) if Ractor.main? && instance_variable_defined?(:@_config) @_config else RactorRailsShim::KAMINARI_SHAREABLE_CONFIG end end def config=(val) RactorRailsShim.storage[#{k_key_str}] = val end RUBY # Register so the shareable fallback builder knows about it. CLASS_ATTRIBUTES << ["Kaminari", :config, k_key, nil] if shareable_config # Patch Kaminari's `page` class method to avoid the `extending` block # (a Proc compiled in the main Ractor). Kaminari defines `page` via eval # with `.extending { include Kaminari::ActiveRecordRelationMethods; # include Kaminari::PageScopeMethods }` — a block that captures the main # Ractor's binding. Calling it from a worker raises "defined with an # un-shareable Proc in a different Ractor". Fix: redefine `page` to pass # the modules as arguments to `extending` (shareable constants, no block). # The delegate classes already include these modules from main's # _share_model_classes!, so the extending is redundant in workers but # harmless (it just re-adds already-included modules). _install_kaminari_page_method_patch end |
._install_kaminari_page_method_patch ⇒ Object
89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 |
# File 'lib/ractor_rails_shim/patches/kaminari.rb', line 89 def _install_kaminari_page_method_patch return if @kaminari_page_patched @kaminari_page_patched = true _register_patch :kaminari_page_method, "8.1" return unless defined?(::Kaminari) return unless defined?(::ActiveRecord::Base) page_method_name = (::Kaminari.config.page_method_name rescue :page).to_s return if page_method_name.empty? # Override on ActiveRecord::Base singleton class (Kaminari defines it # here via eval in the included block of ActiveRecordModelExtension). ::ActiveRecord::Base.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def #{page_method_name}(num = nil) per_page = max_per_page && (default_per_page > max_per_page) ? max_per_page : default_per_page limit(per_page).offset(per_page * ((num = num.to_i - 1) < 0 ? 0 : num)).extending( ::Kaminari::ActiveRecordRelationMethods, ::Kaminari::PageScopeMethods ) end RUBY end |
._install_local_cache_patch ⇒ Object
Patch ActiveSupport::Cache::Strategy::LocalCache#local_cache_key. The
original memoizes the key in a @local_cache_key ivar on the store:
`@local_cache_key ||= "...".to_sym`
When the store is part of the frozen, shared Rails.application graph (deep-frozen by make_app_shareable! for kino :ractor mode), a worker Ractor writing that ivar raises FrozenError. The key is a pure function of the store's class + object_id (both stable for the shared object), so compute it deterministically each call — no ivar write. The key still addresses LocalCacheRegistry, which is already Ractor-safe (it uses IsolatedExecutionState), so each Ractor keeps its own local cache.
383 384 385 386 387 388 389 390 391 392 393 394 395 |
# File 'lib/ractor_rails_shim/patches/active_support.rb', line 383 def _install_local_cache_patch return if @local_cache_patched @local_cache_patched = true _register_patch :local_cache, "8.1" return unless defined?(::ActiveSupport::Cache::Strategy::LocalCache) lc = ::ActiveSupport::Cache::Strategy::LocalCache lc.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def local_cache_key str = "\#{self.class.name.underscore}_local_cache_\#{object_id}".gsub(/[\\/-]/, "_") str.to_sym end RUBY end |
._install_log_subscriber_patch ⇒ Object
Patch ActiveSupport::LogSubscriber.logger — a raw class ivar with lazy init (@logger ||= Rails.logger) that's WRITTEN at request teardown via flush_all!. Workers can't write class ivars → IsolationError. Route through IES; workers get Rails.logger (which the shim already routes through IES) so it resolves to the worker's own per-Ractor logger.
315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 |
# File 'lib/ractor_rails_shim/patches/active_support.rb', line 315 def _install_log_subscriber_patch return if @log_subscriber_patched @log_subscriber_patched = true _register_patch :log_subscriber, "8.1" return unless defined?(::ActiveSupport::LogSubscriber) ls = ::ActiveSupport::LogSubscriber key = :ractor_rails_shim_log_subscriber_logger key_str = key.inspect ls.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def logger v = RactorRailsShim.storage[#{key_str}] return v if RactorRailsShim.storage.key?(#{key_str}) if Ractor.main? && instance_variable_defined?(:@logger) @logger elsif defined?(::Rails) && ::Rails.respond_to?(:logger) ::Rails.logger end end def logger=(val) RactorRailsShim.storage[#{key_str}] = val end RUBY end |
._install_loofah_patch ⇒ Object
Loofah (pulled in by rails-html-sanitizer for sanitize / simple_format)
memoizes @document_klass on its shared DocumentFragment classes. A
worker Ractor cannot set an ivar on a class defined in the main Ractor,
so fragment parsing raises "can not set instance variables of
classes/modules by non-main Ractors". Route the memoization through
Ractor.current (keyed by the fragment class) instead.
717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 |
# File 'lib/ractor_rails_shim/patches/action_view.rb', line 717 def _install_loofah_patch return if @loofah_patched @loofah_patched = true _register_patch :loofah_document_klass, "8.1" return unless defined?(::Loofah::HtmlFragmentBehavior::ClassMethods) mod = ::Loofah::HtmlFragmentBehavior::ClassMethods mod.module_eval do def document_klass store = (Ractor.current[:__rrs_loofah_doc_klass__] ||= {}) store[self.object_id] ||= if Loofah.html5_support? && self == Loofah::HTML5::DocumentFragment Loofah::HTML5::Document elsif self == Loofah::HTML4::DocumentFragment Loofah::HTML4::Document else raise ArgumentError, "unexpected class: #{self}" end end end end |
._install_lookup_context_patch ⇒ Object
67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 |
# File 'lib/ractor_rails_shim/patches/action_view.rb', line 67 def _install_lookup_context_patch return if @lookup_context_patched @lookup_context_patched = true _register_patch :lookup_context, "8.1" return unless defined?(::ActionView::LookupContext) # Force autoload of the nested ActionView::Template constants that the # patched details_cache_key references (Template::Types, # TemplateDetails::Requested). Constants are global, so defining them # here (main ractor) makes them visible to worker ractors, which cannot # autoload. Without this, the first template render in a worker dies on # `NameError: uninitialized constant ActionView::Template::TemplateDetails`. if Ractor.main? && defined?(::ActionView::Template) ::ActionView::Template::Types rescue nil ::ActionView::TemplateDetails rescue nil end lc = ::ActionView::LookupContext key = :ractor_rails_shim_lookup_context_registered_details key_str = key.inspect lc.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def registered_details v = RactorRailsShim.storage[#{key_str}] return v if RactorRailsShim.storage.key?(#{key_str}) if Ractor.main? && instance_variable_defined?(:@registered_details) @registered_details else RactorRailsShim::SHAREABLE_FALLBACK[#{key_str}] || [] end end def registered_details=(val) RactorRailsShim.storage[#{key_str}] = val @registered_details = val if Ractor.main? val end RUBY CLASS_ATTRIBUTES << ["ActionView::LookupContext", :registered_details, key, []] # Redefine default_#{name} methods. register_detail (line 25 of # lookup_context.rb) defines these via Accessors.define_method(:"default_#{name}", &block) # — a Proc from the main ractor. Calling them from a worker raises # "defined with an un-shareable Proc in a different Ractor". # Trigger: when Accept: */* is sent, request.formats returns [Mime::ALL], # and LookupContext#formats= (the override at line 263) does # `values.concat(default_formats) if values.delete "*/*"` — Mime::ALL # compares == to "*/*", so delete removes it and default_formats is called. # Fix: call each block once in main, make the result shareable, and # redefine the method via string eval (no captured binding). accessors = ::ActionView::LookupContext::Accessors ::ActionView::LookupContext.registered_details.each do |name| block = accessors::DEFAULT_PROCS[name] next unless block begin value = block.call rescue StandardError next end begin value = Ractor.make_shareable(value) rescue StandardError value = value.dup.freeze rescue value end const_name = "SHIM_DEFAULT_#{name.upcase}_VALUE" verbose, $VERBOSE = $VERBOSE, nil accessors.const_set(const_name, value) $VERBOSE = verbose accessors.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def default_#{name} ::ActionView::LookupContext::Accessors::#{const_name} end RUBY end # Patch ActionView::Rendering::ClassMethods#view_context_class to read # from a shareable registry (populated in main) instead of building via # Class.new{...} blocks (un-shareable Proc from a worker). The built # class is made shareable; workers read it via the registry. if defined?(::ActionView::Rendering::ClassMethods) rcm = ::ActionView::Rendering::ClassMethods # `ActionView::Base.with_empty_template_cache` is patched separately and # EARLY (see _install_with_empty_template_cache_patch, installed via # ActiveSupport.on_load(:action_view) before eager load) because the # framework's original uses `define_method(:compiled_method_container) # { subclass }` — a block/Proc captured in the main Ractor that raises # "defined with an un-shareable Proc in a different Ractor" when a # worker calls it. In production `DetailsKey.view_context_class` calls # with_empty_template_cache during eager load, so the patch MUST be in # place before then. _install_with_empty_template_cache_patch if defined?(::ActionView::Base) # Build the per-controller view_context_class registry in main. We call # the ORIGINAL build_view_context_class directly (bypassing Rails' # inherit_view_context_class? short-circuit, which otherwise makes # subclasses reuse ActionController::Base's class built with a nil # `_routes` and thus NO route url_helpers). `routes` is forced to the # shareable Rails.application.routes when the controller's own `_routes` # is nil, so named helpers (new_post_path, etc.) are always present. if Ractor.main? registry = {} ::AbstractController::Base.descendants.each do |ctrl| begin routes = ctrl.respond_to?(:_routes) ? ctrl._routes : nil routes = ::Rails.application.routes if routes.nil? && ::Rails.respond_to?(:application) && ::Rails.application vcc = rcm.instance_method(:build_view_context_class).bind(ctrl).call( ::ActionView::LookupContext::DetailsKey.view_context_class, ctrl.respond_to?(:supports_path?) ? ctrl.supports_path? : true, routes, ctrl.respond_to?(:_helpers) ? ctrl._helpers : nil ) registry[ctrl] = vcc if vcc rescue StandardError => e # skip controllers that can't build (e.g. abstract) end end registry.delete_if { |_, v| v.nil? } registry.freeze begin Ractor.make_shareable(registry) self._view_context_registry = registry rescue StandardError => e # If the registry can't be made shareable, leave it — workers fall # back to the empty-cache base. end end rcm.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def view_context_class return @view_context_class if Ractor.main? && instance_variable_defined?(:@view_context_class) && @view_context_class if Ractor.main? @view_context_class ||= build_view_context_class( ActionView::LookupContext::DetailsKey.view_context_class, supports_path?, _routes, _helpers ) return @view_context_class end # Worker Ractor. The frozen shared controller class carries a # memoized @view_context_class built in main (in the registry) with # its proper route url_helpers + controller helpers. Look it up by # controller class; fall back to the shareable fallback class (built # in main with Rails.application.routes) for any controller not # present in the registry at prepare time. vcc = RactorRailsShim._view_context_registry[self] return vcc if vcc RactorRailsShim._view_context_fallback end # `inherit_view_context_class?` (action_view/rendering.rb:52) compares # `superclass._helpers` — and `_helpers` is defined via a block # (redefine_singleton_method) that cannot run in a worker Ractor # ("defined with an un-shareable Proc in a different Ractor"). Return # false so each controller builds its own view_context_class (the # normal Rails behaviour whenever _routes/_helpers differ), avoiding # the `_helpers` comparison entirely. Behaviour is identical in the # main Ractor (the inherited class would be equivalent). def inherit_view_context_class? false end RUBY end vcc_key = :ractor_rails_shim_lookup_context_view_context_class vcc_key_str = vcc_key.inspect CLASS_ATTRIBUTES << ["ActionView::LookupContext::DetailsKey", :view_context_class, vcc_key, nil] # Build it now in main and stash in IES so the fallback builder picks it up. if Ractor.main? && defined?(::ActionView::Base) RactorRailsShim.storage[vcc_key] = ::ActionView::LookupContext::DetailsKey.view_context_class # Shareable fallback view_context_class for any controller not present in # the registry at prepare time. Subclasses ActionView::Base, so it # inherits the per-class compiled_method_container (self.class) and the # route url_helpers. build_view_context_class is a ClassMethods method on # controllers, so invoke it via the unbound method bound to # ActionController::Base. fallback = rcm.instance_method(:build_view_context_class).bind(::ActionController::Base).call( ::ActionView::LookupContext::DetailsKey.view_context_class, true, (defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application) ? ::Rails.application.routes : nil, nil ) fallback = _swallow("make view context fallback shareable") { Ractor.make_shareable(fallback) } self._view_context_fallback = fallback end dk = ::ActionView::LookupContext::DetailsKey dk_key = :ractor_rails_shim_lookup_context_details_keys dc_key = :ractor_rails_shim_lookup_context_digest_cache dk_key_str = dk_key.inspect dc_key_str = dc_key.inspect vcc_key_str2 = vcc_key.inspect dk.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def view_context_class v = RactorRailsShim.storage[#{vcc_key_str2}] return v if RactorRailsShim.storage.key?(#{vcc_key_str2}) if Ractor.main? && instance_variable_defined?(:@view_context_class) v = @view_context_class RactorRailsShim.storage[#{vcc_key_str2}] = v v else RactorRailsShim::SHAREABLE_FALLBACK[#{vcc_key_str2}] end end def details_keys v = RactorRailsShim.storage[#{dk_key_str}] return v if v if Ractor.main? && instance_variable_defined?(:@details_keys) v = @details_keys else v = Concurrent::Map.new end RactorRailsShim.storage[#{dk_key_str}] = v v end def digest_cache(details) dc = (RactorRailsShim.storage[#{dc_key_str}] ||= Concurrent::Map.new) dc[details_cache_key(details)] ||= Concurrent::Map.new end def details_cache_key(details) details_keys.fetch(details) do if formats = details[:formats] unless ::ActionView::Template::Types.valid_symbols?(formats) details = details.dup details[:formats] &= ::ActionView::Template::Types.symbols end end details_keys[details] ||= ::ActionView::TemplateDetails::Requested.new(**details) end end RUBY end |
._install_mail_patch ⇒ Object
21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 |
# File 'lib/ractor_rails_shim/patches/mail.rb', line 21 def _install_mail_patch return if @mail_patched @mail_patched = true _register_patch :mail, "8.1" return unless defined?(::Mail) # --- Mail::Message.default_charset (read in Mail::Message#initialize) --- if defined?(::Mail::Message) ::Mail::Message.singleton_class.class_eval do def default_charset if ::Ractor.main? @@default_charset else ::RactorRailsShim.storage[:ractor_rails_shim_mail_default_charset] || "UTF-8" end end def default_charset=(charset) if ::Ractor.main? @@default_charset = charset ::RactorRailsShim.storage[:ractor_rails_shim_mail_default_charset] = charset else ::RactorRailsShim.storage[:ractor_rails_shim_mail_default_charset] = charset end end end end # --- Mail::Header.maximum_amount (read while parsing/building headers) --- if defined?(::Mail::Header) ::Mail::Header.singleton_class.class_eval do def maximum_amount if ::Ractor.main? @@maximum_amount else ::RactorRailsShim.storage[:ractor_rails_shim_mail_max_amount] || 1000 end end def maximum_amount=(value) if ::Ractor.main? @@maximum_amount = value ::RactorRailsShim.storage[:ractor_rails_shim_mail_max_amount] = value else ::RactorRailsShim.storage[:ractor_rails_shim_mail_max_amount] = value end end end end # --- ActionMailer::Base.mailer_name (raw class ivar on the mailer subclass) --- # `mailer_name` (aliased to `controller_path`) computes the mailer's view # path prefix from its class name, but reads the lazy `@mailer_name` class # ivar — unreadable from a worker Ractor. The value defaults to # `name.underscore`, which is derivable from the (shareable) class name, so # just compute it in workers. if defined?(::ActionMailer::Base) _install_actionmailer_mailer_name_patch elsif defined?(::ActiveSupport.on_load) # ActionMailer not loaded yet (e.g. shim `install` runs before # `require "rails/all"`). Register the patch to fire the moment # ActionMailer::Base is loaded — BEFORE the app eager-loads its mailers, # so the `local_prefixes` -> `[controller_path]` -> `mailer_name` call # sites compile against the patched method (a late `prepare_for_ractors!` # patch binds too late: those call sites have already captured the # original `@mailer_name`-reading method). ::ActiveSupport.on_load(:action_mailer) do RactorRailsShim.send(:_install_actionmailer_mailer_name_patch) end end # --- Freeze all un-shareable `mail` gem constants so workers can read them --- # The `mail` gem defines many Regexp / mutable-Array / mutable-Hash module # constants (e.g. Mail::Utilities::TO_CRLF_REGEX, Mail::SMTP::DEFAULTS). # Reading an un-shareable constant from a non-main Ractor raises # IsolationError, so make every constant value under `Mail` shareable (in # place — freezing Regexps/Arrays/Hashes). Done once at install; constants # are fixed at boot so freezing after boot is safe. if defined?(::Mail) _make_mail_constants_shareable! end # --- Mail::Encodings registry (a module ivar, read during message build) --- # `Mail::Encodings.@transfer_encodings` is a module-level ivar holding the # registered transfer encodings (base64, quoted_printable, ...). Reading it # from a worker Ractor raises IsolationError. The registry is fixed at boot, # so capture a frozen shareable copy and have workers read it. if defined?(::Mail::Encodings) enc_reg = nil begin enc_reg = ::Mail::Encodings.instance_variable_get(:@transfer_encodings) rescue StandardError enc_reg = nil end if enc_reg RactorRailsShim.const_set( :SHAREABLE_MAIL_TRANSFER_ENCODINGS, Ractor.make_shareable(enc_reg.dup) ) end ::Mail::Encodings.singleton_class.class_eval do def get_encoding(name) reg = ::Ractor.main? ? @transfer_encodings : ::RactorRailsShim::SHAREABLE_MAIL_TRANSFER_ENCODINGS reg[get_name(name)] end def get_all reg = ::Ractor.main? ? @transfer_encodings : ::RactorRailsShim::SHAREABLE_MAIL_TRANSFER_ENCODINGS reg.values end def defined?(name) reg = ::Ractor.main? ? @transfer_encodings : ::RactorRailsShim::SHAREABLE_MAIL_TRANSFER_ENCODINGS reg.include? get_name(name) end end end # --- Mail.delivery_method (resolves Mail::Configuration, a Singleton) --- # `Mail.delivery_method` calls `Mail::Configuration.instance` — a Singleton # whose class ivar (`@singleton__mutex__`) is un-shareable, so reading it # from a worker Ractor raises IsolationError. Capture the RESOLVED # delivery-method CLASS at install (main) into a shareable constant; workers # build a fresh, worker-local instance of it instead of touching the # Singleton. The `:test` delivery instance only appends to # `TestMailer.deliveries` (class-level, patched above), so a fresh instance # per call is correct. dm_class = nil begin resolved = ::Mail::Configuration.instance.delivery_method dm_class = resolved.class if resolved rescue StandardError dm_class = nil end if dm_class RactorRailsShim.const_set( :SHAREABLE_MAIL_DELIVERY_METHOD_CLASS, Ractor.make_shareable(dm_class) ) end ::Mail.singleton_class.class_eval do alias_method :_rrs_orig_delivery_method, :delivery_method def delivery_method(method = nil, settings = {}) if ::Ractor.main? _rrs_orig_delivery_method(method, settings) else klass = ::RactorRailsShim::SHAREABLE_MAIL_DELIVERY_METHOD_CLASS return klass.new(settings) if klass _rrs_orig_delivery_method(method, settings) end end end # --- Mail::Configuration.instance (a Singleton) --- # `Mail::Message#delivery_method` calls `Mail::Configuration.instance` # directly. The Singleton's `instance` reads the un-shareable class ivar # `@singleton__instance__`, which raises IsolationError in a worker Ractor. # Build a fresh, worker-local Configuration instance instead (the Singleton # makes `new` private, so use `allocate` + `initialize`). A fresh instance # is correct: delivery settings are derived per-call from the method name. if defined?(::Mail::Configuration) unless ::Mail::Configuration.singleton_class.private_instance_methods .include?(:_rrs_orig_configuration_instance) ::Mail::Configuration.singleton_class.class_eval do alias_method :_rrs_orig_configuration_instance, :instance end end ::Mail::Configuration.singleton_class.class_eval do def instance if ::Ractor.main? _rrs_orig_configuration_instance else cfg = ::Mail::Configuration.send(:allocate) cfg.send(:initialize) cfg end end end end # --- Mail delivery observers / interceptors --- # `inform_interceptors` / `inform_observers` read `@@delivery_interceptors` # / `@@delivery_notification_observers` INLINE (not via the reader), so the # readers alone don't help. Patch the call sites to read the per-Ractor IES # slot. Workers have no registered observers/interceptors, so an empty array # is correct. ::Mail.singleton_class.class_eval do def inform_interceptors(mail_obj) interceptors = if ::Ractor.main? @@delivery_interceptors else ::RactorRailsShim.storage[:ractor_rails_shim_mail_interceptors] ||= [] end interceptors.each { |i| i.delivering_email(mail_obj) } end def inform_observers(mail_obj) observers = if ::Ractor.main? @@delivery_notification_observers else ::RactorRailsShim.storage[:ractor_rails_shim_mail_observers] ||= [] end observers.each { |o| o.delivered_email(mail_obj) } end # `uniq` does `@@uniq += 1` (a cvar WRITE) — also illegal in workers. # Use a per-Ractor IES counter instead. def uniq if ::Ractor.main? @@uniq += 1 else n = (::RactorRailsShim.storage[:ractor_rails_shim_mail_uniq] || 0) + 1 ::RactorRailsShim.storage[:ractor_rails_shim_mail_uniq] = n n end end end # --- Mail::TestMailer.deliveries (ActionMailer's :test delivery appends here) --- if defined?(::Mail::TestMailer) ::Mail::TestMailer.singleton_class.class_eval do def deliveries if ::Ractor.main? @@deliveries ||= [] else ::RactorRailsShim.storage[:ractor_rails_shim_mail_test_deliveries] ||= [] end end def deliveries=(val) if ::Ractor.main? @@deliveries = val ::RactorRailsShim.storage[:ractor_rails_shim_mail_test_deliveries] = val else ::RactorRailsShim.storage[:ractor_rails_shim_mail_test_deliveries] = val end end end end # --- Mail::Parsers::*Parser class ivars (Citrus/Ragel parse tables) --- # The `mail` gem's generated parsers (MimeVersionParser, AddressParser, …) # are MODULES whose `class << self` block stores parse tables as singleton # ivars (`@_index_offsets`, `@_trans_keys`, `@_key_spans`, …). These are # populated at file-load time and are deterministic/shareable once frozen, # but reading a class/module ivar from a non-main Ractor raises # IsolationError. Capture each value once (in the main Ractor) and redefine # the attr reader to return the captured, shareable copy — so worker calls # to `_index_offsets` / `_trans_keys` / … return the same table. # --- Module-level ivars on `mail` gem modules (Citrus/Ragel parse tables, # `Mail::Utilities#charset_encoder`, …) --- # These modules store state as class/module ivars (`@_index_offsets`, # `@_trans_keys`, `@charset_encoder`, …) populated at file-load time. # Reading a module ivar from a non-main Ractor raises IsolationError, so # capture each value once (in the main Ractor) into a shareable constant # and redefine the attr reader to return the captured copy (via # string-eval `def` — NOT `define_method`, whose block would be an # un-shareable Proc). This covers `Mail::Parsers::*Parser` and # `Mail::Utilities` (which uses `attr_accessor :charset_encoder`). target_modules = [] if defined?(::Mail::Parsers) ::Mail::Parsers.constants(false).each do |pname| pmod = ::Mail::Parsers.const_get(pname) rescue nil target_modules << pmod if pmod.is_a?(Module) end end target_modules << ::Mail::Utilities if defined?(::Mail::Utilities) target_modules.each do |pmod| ivs = pmod.instance_variables next if ivs.empty? captured = {} ivs.each do |iv| val = pmod.instance_variable_get(iv) rescue nil captured[iv] = ::Ractor.make_shareable(val) rescue val end captured.freeze const_name = :RRS_MODULE_IVARS unless pmod.singleton_class.const_defined?(const_name) pmod.singleton_class.const_set(const_name, captured) end pmod.singleton_class.class_eval do captured.each do |iv, val| reader = iv.to_s.sub(/\A@/, "").to_sym if method_defined?(reader, true) && !val.nil? class_eval(<<~RUBY) def #{reader} ::#{pmod}.singleton_class::#{const_name}[#{iv.inspect}] end RUBY end end end end # --- Mail::PartsList / Mail::AttachmentsList (DelegateClass(Array)) --- # `DelegateClass(Array)` generates each delegating method (`[]`, `<<`, `each`, # …) via a block compiled in the main Ractor, so the methods are un-shareable # Procs that raise "defined with an un-shareable Proc in a different Ractor" # when called from a worker Ractor (hit during message building: # `Mail::Body#<<` -> `Mail::PartsList.new[val]`). Redefine the delegating # methods as string-eval `def`s (shareable) that forward to `__getobj__` — # identical behavior to DelegateClass, but callable from any Ractor. We skip # methods the class defines itself (e.g. `PartsList#collect`) so we don't # clobber their (already shareable) custom implementations. if defined?(::Mail::PartsList) || defined?(::Mail::AttachmentsList) [::Mail::PartsList, ::Mail::AttachmentsList].each do |klass| next unless klass.is_a?(Class) # `DelegateClass` also compiles `__getobj__` / `__setobj__` (and the # inherited `initialize`, which calls `__setobj__`) as Procs in the main # Ractor, so redefine those shareably too. The backing ivar is # `@delegate_dc_obj` (verified against Ruby's DelegateClass). klass.class_eval(<<~RUBY) def __getobj__ @delegate_dc_obj end def __setobj__(obj) @delegate_dc_obj = obj end # Shadow the DelegateClass-generated `initialize` (a Proc compiled in # the main Ractor). Reproduce PartsList's own init: build the backing # Array and point the delegation ivar at it directly, so `super` never # reaches the un-shareable DelegateClass constructor. def initialize(*args) @parts = Array.new(*args) @delegate_dc_obj = @parts end RUBY ::Array.instance_methods(false).each do |m| next if %i[object_id __send__ __id__ equal?].include?(m) im = klass.instance_method(m) rescue nil next if im && im.owner == klass klass.class_eval(<<~RUBY) def #{m}(*args, &block) __getobj__.send(:#{m}, *args, &block) end RUBY end klass.class_eval(<<~RUBY) def method_missing(name, *args, &block) __getobj__.send(name, *args, &block) end def respond_to_missing?(name, include_private = false) __getobj__.respond_to?(name, include_private) || super end RUBY end end end |
._install_marcel_patch ⇒ Object
190 |
# File 'lib/ractor_rails_shim/patches/marcel.rb', line 190 def self._install_marcel_patch = MarcelPatch.apply! |
._install_messages_serializer_patch ⇒ Object
Patch ActiveSupport::Messages::SerializerWithFallback. Its SERIALIZERS
constant is a Hash of serializer modules — but the Hash itself is not
Ractor-shareable, so a worker Ractor reading it raises "can not access
non-shareable objects in constant ...SERIALIZERS". The individual
serializer modules ARE shareable, so route the lookup through
IsolatedExecutionState (a per-Ractor cache of the same module
references, which workers can read). .load resolves the fallback
serializer the same way.
427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 |
# File 'lib/ractor_rails_shim/patches/active_support.rb', line 427 def return if @messages_serializer_patched @messages_serializer_patched = true _register_patch :messages_serializer, "8.1" return unless defined?(::ActiveSupport::Messages::SerializerWithFallback) swf = ::ActiveSupport::Messages::SerializerWithFallback swf.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def serializer_for(format) if Ractor.main? SERIALIZERS.fetch(format) else (RactorRailsShim.storage[:ractor_rails_shim_serializers] ||= { marshal: ::ActiveSupport::Messages::SerializerWithFallback::MarshalWithFallback, json: ::ActiveSupport::Messages::SerializerWithFallback::JsonWithFallback, json_allow_marshal: ::ActiveSupport::Messages::SerializerWithFallback::JsonWithFallbackAllowMarshal, message_pack: ::ActiveSupport::Messages::SerializerWithFallback::MessagePackWithFallback, message_pack_allow_marshal: ::ActiveSupport::Messages::SerializerWithFallback::MessagePackWithFallbackAllowMarshal, })[format] end end def [](format) if format.to_s.include?("message_pack") && !defined?(::ActiveSupport::MessagePack) require "active_support/message_pack" end serializer_for(format) end RUBY swf.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def load(dumped) format = detect_format(dumped) if format == self.format _load(dumped) elsif format && fallback?(format) payload = { serializer: self.format, fallback: format, serialized: dumped } ActiveSupport::Notifications.instrument("message_serializer_fallback.active_support", payload) do payload[:deserialized] = serializer_for(format)._load(dumped) end else raise "Unsupported serialization format" end end RUBY # MessagePackWithFallback#available? lazily memoizes `@available` directly # on the module. When a worker Ractor first deserializes a cookie, # SerializerWithFallback#load -> detect_format -> MessagePackWithFallback # .dumped? -> available? tries to SET that ivar, raising # Ractor::IsolationError: can not set instance variables of # classes/modules by non-main Ractors # Replace the ivar memoization with a pure constant check. The module is # shareable and ActiveSupport::MessagePack resolves to a shareable class, # so this is safe from any Ractor. ::ActiveSupport::Messages::SerializerWithFallback::MessagePackWithFallback.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def available? defined?(::ActiveSupport::MessagePack) end RUBY end |
._install_mime_negotiation_worker_patch ⇒ Object
Patch ActionDispatch::Http::MimeNegotiation#formats and #negotiate_mime
to be worker-Ractor safe. Mime[:html] reads from Mime::EXTENSION_LOOKUP,
a mutable Hash that is NOT shareable (Ractor.make_shareable fails because
Mime::Type objects hold unshareable state). A worker Ractor reading
EXTENSION_LOOKUP raises Ractor::IsolationError, which propagates up as
an UnknownFormat from Devise's respond_with. Fix: cache the commonly-used
Mime types in a shareable IES slot at prepare time; the patched formats
method uses the shareable lookup in workers instead of the raw constant.
800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 |
# File 'lib/ractor_rails_shim/patches/action_dispatch.rb', line 800 def _install_mime_negotiation_worker_patch return if @mime_neg_worker_patched @mime_neg_worker_patched = true _register_patch :mime_negotiation_worker, "8.1" return unless defined?(::ActionDispatch::Http::MimeNegotiation) # Capture shareable Mime type references at prepare time (main Ractor). if Ractor.main? begin shareable = {} [:html, :json, :xml, :js, :text, :csv, :pdf, :zip, :all].each do |sym| mt = ::Mime[sym] rescue nil next unless mt ::Ractor.make_shareable(mt) rescue nil shareable[sym] = mt end ::Ractor.make_shareable(shareable) rescue nil _reassign_shareable_const(:SHAREABLE_MIME_TYPES, shareable) rescue StandardError => e # best-effort end end mod = ::ActionDispatch::Http::MimeNegotiation mod.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def formats fetch_header("action_dispatch.request.formats") do |k| v = if params_readable? fmt = begin; ::Mime[parameters[:format]]; rescue StandardError; nil; end if fmt [fmt] elsif Ractor.main? [::Mime[:html]].compact else sm = RactorRailsShim::SHAREABLE_MIME_TYPES rescue nil sm ? [sm[:html]].compact : [] end elsif use_accept_header && valid_accept_header accepts.dup elsif extension_format = format_from_path_extension [extension_format] elsif xhr? if Ractor.main? [::Mime[:js]].compact else sm = RactorRailsShim::SHAREABLE_MIME_TYPES rescue nil sm ? [sm[:js]].compact : [] end else if Ractor.main? [::Mime[:html]].compact else sm = RactorRailsShim::SHAREABLE_MIME_TYPES rescue nil sm ? [sm[:html]].compact : [] end end v.select! do |format| format.symbol || format.ref == "*/*" end set_header k, v end end RUBY # Patch the Collector#negotiate_format to use the shareable Mime types # when comparing against request.formats. The Collector is created inside # respond_with and holds @responses keyed by Mime::Type objects. If those # objects were created in a worker (via Mime[:html] on a non-shareable # EXTENSION_LOOKUP), they may be different instances than the ones in # request.formats. Use equal? comparison via the shareable snapshot to # ensure identity. if defined?(::ActionController::MimeResponds::Collector) ::ActionController::MimeResponds::Collector.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def negotiate_format(request) @format = request.negotiate_mime(@responses.keys) # Worker fallback: if negotiation failed but the request accepts # HTML and we have an HTML response, return it. This handles the # case where Mime type identity comparison fails across Ractors # due to different object instances. if @format.nil? && @responses.keys.any? sm = (RactorRailsShim::SHAREABLE_MIME_TYPES rescue nil) if sm req_fmts = request.formats rescue [] req_fmts.each do |rf| @responses.keys.each do |rk| if rf.respond_to?(:symbol) && rk.respond_to?(:symbol) && rf.symbol == rk.symbol @format = rk return @format end end end end end @format end RUBY end end |
._install_module_introspection_patch ⇒ Object
Patch Module#module_parent_name so a worker Ractor does not write the
@parent_name class ivar on a shared (non-frozen) module. The default
memoizes @parent_name ||= ... on first use; when that first use happens
in a worker it writes a class ivar on a shared module, which raises
Ractor::IsolationError ("can not set instance variables of
classes/modules by non-main Ractors"). Route the per-worker cache through
IsolatedExecutionState (keyed by module object_id); main keeps the
original class-ivar behavior.
178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 |
# File 'lib/ractor_rails_shim/patches/active_support.rb', line 178 def _install_module_introspection_patch return if @module_introspection_patched @module_introspection_patched = true _register_patch :module_introspection, "8.1" return unless defined?(::Module) ::Module.module_eval do def module_parent_name if defined?(@parent_name) @parent_name else name = self.name return if name.nil? parent_name = name =~ /::[^:]+\z/ ? -$` : nil if Ractor.main? @parent_name = parent_name unless frozen? else store = (RactorRailsShim.storage[:rrs_module_parent_names] ||= {}) store[object_id] ||= parent_name end parent_name end end end end |
._install_notifications_notifier_patch ⇒ Object
Patch ActiveSupport::Notifications.notifier to not read the @notifier
class ivar from a worker Ractor. The original is attr_accessor :notifier with @notifier = Fanout.new set at module load — a raw
class ivar holding a Fanout (which has a Mutex + subscriber Procs,
both unshareable). Workers get their own per-Ractor Fanout (no
subscribers — instrumentation is a no-op in workers, which is correct
for a read-only shared app where log subscribers already ran in main).
notifier is read by instrumenter (per-request via Rails::Rack::Logger).
- Moved here from execution_wrapper.rb (it patches ActiveSupport
Notifications, not ExecutionWrapper).
95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 |
# File 'lib/ractor_rails_shim/patches/active_support.rb', line 95 def _install_notifications_notifier_patch return if @notifications_notifier_patched @notifications_notifier_patched = true _register_patch :notifications_notifier, "8.1" return unless defined?(::ActiveSupport::Notifications) notif = ::ActiveSupport::Notifications nkey = :ractor_rails_shim_notifications_notifier nkey_str = nkey.inspect notif.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def notifier v = RactorRailsShim.storage[#{nkey_str}] return v if RactorRailsShim.storage.key?(#{nkey_str}) if Ractor.main? && instance_variable_defined?(:@notifier) @notifier else built = ActiveSupport::Notifications::Fanout.new RactorRailsShim.storage[#{nkey_str}] = built built end end RUBY end |
._install_openssl_digest_patch ⇒ Object
29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 |
# File 'lib/ractor_rails_shim/patches/openssl.rb', line 29 def _install_openssl_digest_patch return if @openssl_digest_patched @openssl_digest_patched = true _register_patch :openssl_digest, "8.1" return unless defined?(::OpenSSL::Digest) algos = %w(MD4 MD5 RIPEMD160 SHA1 SHA224 SHA256 SHA384 SHA512) algos.each do |name| klass_name = name.tr("-", "_") next unless ::OpenSSL::Digest.const_defined?(klass_name) klass = ::OpenSSL::Digest.const_get(klass_name) next unless klass.is_a?(::Class) # Replace the lambda-based `initialize` (super(name, data)) with a # string-eval'd method that holds no captured Proc. klass.class_eval "def initialize(data = nil); super(#{name.inspect}, data); end" # Replace the singleton `digest`/`hexdigest` blocks the same way. klass.singleton_class.class_eval <<-RUBY, __FILE__, __LINE__ + 1 def digest(data) new.digest(data) end def hexdigest(data) new.hexdigest(data) end RUBY end end |
._install_orm_adapter_patch ⇒ Object
14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
# File 'lib/ractor_rails_shim/patches/orm_adapter.rb', line 14 def _install_orm_adapter_patch return if @orm_adapter_patched @orm_adapter_patched = true _register_patch :orm_adapter, "8.1" return unless defined?(::OrmAdapter::ToAdapter) ::OrmAdapter::ToAdapter.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def to_adapter key = :"ractor_rails_shim_orm_adapter_\#{object_id}" v = RactorRailsShim.storage[key] return v if RactorRailsShim.storage.key?(key) adapter = self::OrmAdapter.new(self) RactorRailsShim.storage[key] = adapter adapter end RUBY end |
._install_parameter_encoding_patch ⇒ Object
Patch ActionController::ParameterEncoding::ClassMethods#action_encoding_template
to not read @_parameter_encodings (a raw class ivar) from a worker
Ractor. The default is an empty-ish Hash; for a frozen shared app workers
get an empty frozen Hash (no per-action param encodings — correct for
apps that don't declare parameter_encoding, e.g. the health controller).
34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 |
# File 'lib/ractor_rails_shim/patches/action_controller.rb', line 34 def _install_parameter_encoding_patch return if @param_encoding_patched @param_encoding_patched = true _register_patch :parameter_encoding, "8.1" return unless defined?(::ActionController::ParameterEncoding) pe = ::ActionController::ParameterEncoding::ClassMethods pe.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def action_encoding_template(action) enc = if Ractor.main? instance_variable_defined?(:@_parameter_encodings) ? @_parameter_encodings : nil else RactorRailsShim.storage[:ractor_rails_shim_param_encodings] end if enc && enc.has_key?(action.to_s) enc[action.to_s] end end RUBY end |
._install_path_registry_patch ⇒ Object
Patch ActionView::PathRegistry to not read its raw class ivars
(@view_paths_by_class, @file_system_resolvers) from a worker Ractor.
These are populated at boot (view paths registered by the app). For a
frozen shared app they're read-only; workers read them via the shareable
fallback (built from main's values, made shareable). get_view_paths is
called per-request during view lookup; all_file_system_resolvers is
called by the exception backtrace builder.
398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 |
# File 'lib/ractor_rails_shim/patches/action_view.rb', line 398 def _install_path_registry_patch return if @path_registry_patched @path_registry_patched = true _register_patch :path_registry, "8.1" return unless defined?(::ActionView::PathRegistry) pr = ::ActionView::PathRegistry vpc_key = :ractor_rails_shim_path_registry_view_paths_by_class fsr_key = :ractor_rails_shim_path_registry_file_system_resolvers vpc_key_str = vpc_key.inspect fsr_key_str = fsr_key.inspect pr.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 # View paths are keyed by CLASS NAME (not the class object). In # ractor mode the worker Ractors rebind the app's constants into their # own namespace, so a worker's controller class is a DIFFERENT object # than the one used as the key in the main Ractor — keying by the # (stable) class name makes the shareable fallback resolve correctly # across that rebinding. A nil class (end of the ancestor walk) # terminates the recursion instead of calling #superclass on nil. def get_view_paths(klass) return [] if klass.nil? name = klass.respond_to?(:name) ? klass.name : nil h = RactorRailsShim.storage[#{vpc_key_str}] h = (Ractor.main? ? (instance_variable_defined?(:@view_paths_by_class) ? instance_variable_get(:@view_paths_by_class) : {}) : RactorRailsShim::SHAREABLE_FALLBACK[#{vpc_key_str}]) if h.nil? return h[name] if name && h.key?(name) get_view_paths(klass.superclass) end def set_view_paths(klass, paths) name = klass.respond_to?(:name) ? klass.name : nil return unless name h = RactorRailsShim.storage[#{vpc_key_str}] if h.nil? # Adopt any class-ivar entries Rails populated with Class-object keys # before this patch installed, re-keying them by (stable) class NAME # so the shareable fallback resolves across worker rebinding (a # worker's controller class is a different object than main's). h = {} if Ractor.main? && instance_variable_defined?(:@view_paths_by_class) old = instance_variable_get(:@view_paths_by_class) old.each do |k, v| nk = k.respond_to?(:name) ? k.name : k h[nk] = v if nk end end RactorRailsShim.storage[#{vpc_key_str}] = h end h[name] = paths instance_variable_set(:@view_paths_by_class, h) if Ractor.main? end def all_file_system_resolvers h = RactorRailsShim.storage[#{fsr_key_str}] h = (Ractor.main? ? (instance_variable_defined?(:@file_system_resolvers) ? instance_variable_get(:@file_system_resolvers) : {}) : RactorRailsShim::SHAREABLE_FALLBACK[#{fsr_key_str}]) if h.nil? h.values end # all_resolvers reads @view_paths_by_class directly (an unshareable # class ivar), which raises Ractor::IsolationError in a worker Ractor # and is hit while building an exception backtrace (masking the real # error). Route it through IES + the shareable fallback like the # other PathRegistry accessors. def all_resolvers h = RactorRailsShim.storage[#{vpc_key_str}] h = (Ractor.main? ? (instance_variable_defined?(:@view_paths_by_class) ? instance_variable_get(:@view_paths_by_class) : {}) : RactorRailsShim::SHAREABLE_FALLBACK[#{vpc_key_str}]) if h.nil? resolvers = [all_file_system_resolvers] resolvers.concat h.values.map(&:to_a) resolvers.flatten.uniq end RUBY if Ractor.main? # Rails populates @view_paths_by_class with Class-object keys (via the # original set_view_paths, which runs before this patch installs). Re-key # by (stable) class NAME so the shareable fallback resolves across worker # constant rebinding (a worker's controller class is a different object # than main's). @file_system_resolvers is already keyed by path string. if pr.instance_variable_defined?(:@view_paths_by_class) old = pr.instance_variable_get(:@view_paths_by_class) rekeyed = {} old.each do |k, v| nk = k.respond_to?(:name) ? k.name : k rekeyed[nk] = v if nk end pr.instance_variable_set(:@view_paths_by_class, rekeyed) end end # Register so the fallback builder captures + shares these. CLASS_ATTRIBUTES << ["ActionView::PathRegistry", :view_paths_by_class, vpc_key, {}] CLASS_ATTRIBUTES << ["ActionView::PathRegistry", :file_system_resolvers, fsr_key, {}] end |
._install_polymorphic_routes_patch ⇒ Object
21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 |
# File 'lib/ractor_rails_shim/patches/polymorphic_routes.rb', line 21 def _install_polymorphic_routes_patch return if @polymorphic_routes_patched @polymorphic_routes_patched = true _register_patch :polymorphic_routes, "8.1" return unless defined?(::ActionDispatch::Routing::PolymorphicRoutes::HelperMethodBuilder) hmb = ::ActionDispatch::Routing::PolymorphicRoutes::HelperMethodBuilder hmb.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def get(action, type) type = type.to_sym build action, type end def url build nil, "url" end def path build nil, "path" end RUBY hmb.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def handle_model_call(target, record) if mapping = polymorphic_mapping(target, record) rescue nil mapping.call(target, [record], suffix == "path") else method, args = handle_model(record) target.public_send(method, *args) end end RUBY # The module-level `polymorphic_path` / `polymorphic_url` (called by # `form_with` when it infers the URL from a model) invoke # `mapping.call` directly. A custom `resolve` mapping Proc is built in # the main Ractor and is un-shareable, so calling it from a worker # Ractor raises "defined with an un-shareable Proc in a different # Ractor". Rescue that and fall through to the (worker-safe) # HelperMethodBuilder path, which derives the route from the record's # model name — the same fallback the original code uses when no mapping # is registered at all. pm = ::ActionDispatch::Routing::PolymorphicRoutes pm.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def polymorphic_path(record_or_hash_or_array, options = {}) if ::Hash === record_or_hash_or_array options = record_or_hash_or_array.merge(options) record = options.delete :id return polymorphic_path record, options end if mapping = (polymorphic_mapping(record_or_hash_or_array) rescue nil) begin return mapping.call(self, [record_or_hash_or_array, options], true) rescue ::Ractor::Error end end opts = options.dup action = opts.delete :action type = :path HelperMethodBuilder.polymorphic_method self, record_or_hash_or_array, action, type, opts end def polymorphic_url(record_or_hash_or_array, options = {}) if ::Hash === record_or_hash_or_array options = record_or_hash_or_array.merge(options) record = options.delete :id return polymorphic_url record, options end if mapping = (polymorphic_mapping(record_or_hash_or_array) rescue nil) begin return mapping.call(self, [record_or_hash_or_array, options], false) rescue ::Ractor::Error end end opts = options.dup action = opts.delete :action type = opts.delete(:routing_type) || :url HelperMethodBuilder.polymorphic_method self, record_or_hash_or_array, action, type, opts end RUBY end |
._install_propshaft_patch ⇒ Object
27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 |
# File 'lib/ractor_rails_shim/patches/propshaft.rb', line 27 def _install_propshaft_patch return if @propshaft_patched @propshaft_patched = true _register_patch :propshaft, "1.3" return unless defined?(::Propshaft::LoadPath) lp = ::Propshaft::LoadPath lp.class_eval <<-RUBY, __FILE__, __LINE__ + 1 def asset_paths_by_type(content_type) cache = (RactorRailsShim.storage[:"ractor_rails_shim_propshaft_type_\#{object_id}"] ||= {}) if cache.key?(content_type) cache[content_type] else cache[content_type] = extract_logical_paths_from(assets.select { |a| a.content_type == ::Mime::EXTENSION_LOOKUP[content_type] }) end end def asset_paths_by_glob(glob) cache = (RactorRailsShim.storage[:"ractor_rails_shim_propshaft_glob_\#{object_id}"] ||= {}) if cache.key?(glob) cache[glob] else cache[glob] = extract_logical_paths_from(assets.select { |a| a.path.fnmatch?(glob) }) end end RUBY if lp.method_defined?(:asset_paths_by_path) lp.class_eval <<-RUBY, __FILE__, __LINE__ + 1 def asset_paths_by_path(path) cache = (RactorRailsShim.storage[:"ractor_rails_shim_propshaft_path_\#{object_id}"] ||= {}) if cache.key?(path) cache[path] else cache[path] = extract_logical_paths_from(assets.select { |a| a.path.fnmatch?(path) }) end end RUBY end end |
._install_query_parser_patch ⇒ Object
ActionDispatch::QueryParser.each_pair returns enum_for(:each_pair, s, separator) when called without a block. The Enumerator wraps a Proc that
was compiled in the main Ractor, so when a worker Ractor iterates it
(pairs.each inside ParamBuilder#from_pairs) Ruby raises "defined with an
un-shareable Proc in a different Ractor". Redefine it to materialize into a
plain (shareable) frozen Array using a block-free loop, so worker Ractors
can parse form/query pairs without crossing Ractor boundaries.
124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 |
# File 'lib/ractor_rails_shim/patches/action_dispatch.rb', line 124 def _install_query_parser_patch return if @query_parser_patched @query_parser_patched = true return unless defined?(::ActionDispatch::QueryParser) ::ActionDispatch::QueryParser.singleton_class.module_eval <<-'RUBY', __FILE__, __LINE__ + 1 def each_pair(s, separator = nil) return _materialized_pairs(s, separator) unless block_given? s ||= "" splitter = if separator ::ActionDispatch::QueryParser::COMMON_SEP[separator] || /[#{separator}] */n else ::ActionDispatch::QueryParser::DEFAULT_SEP end s.split(splitter).each do |part| next if part.empty? k, v = part.split("=", 2) k = URI.decode_www_form_component(k) v &&= URI.decode_www_form_component(v) yield k, v end nil end def _materialized_pairs(s, separator) s ||= "" splitter = if separator ::ActionDispatch::QueryParser::COMMON_SEP[separator] || /[#{separator}] */n else ::ActionDispatch::QueryParser::DEFAULT_SEP end parts = s.split(splitter) result = [] i = 0 while i < parts.length part = parts[i] i += 1 if part.empty? next end kv = part.split("=", 2) k = URI.decode_www_form_component(kv[0]) v = kv[1] && URI.decode_www_form_component(kv[1]) result << [k, v] end result.freeze end RUBY end |
._install_rack_request_patch ⇒ Object
Patch Rack::Request's class-level attr_accessors (forwarded_priority, x_forwarded_proto_priority) to not read @ivars from a worker Ractor. The values are frozen-Symbol Arrays (shareable); route the cache through IES with the same default in workers. Read per-request via ActionDispatch::RemoteIp. Applied at prepare_for_ractors! time (after Rails boots, so Rack is loaded).
46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 |
# File 'lib/ractor_rails_shim/patches/rack.rb', line 46 def _install_rack_request_patch return if @rack_request_patched @rack_request_patched = true _register_patch :rack_request, "8.1" return unless defined?(::Rack::Request) req = ::Rack::Request fp_key = :ractor_rails_shim_rack_forwarded_priority xp_key = :ractor_rails_shim_rack_x_forwarded_proto_priority fp_key_str = fp_key.inspect xp_key_str = xp_key.inspect req.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def forwarded_priority v = RactorRailsShim.storage[#{fp_key_str}] return v if RactorRailsShim.storage.key?(#{fp_key_str}) if Ractor.main? && instance_variable_defined?(:@forwarded_priority) @forwarded_priority else [:forwarded, :x_forwarded] end end def forwarded_priority=(val) RactorRailsShim.storage[#{fp_key_str}] = val @forwarded_priority = val if Ractor.main? val end def x_forwarded_proto_priority v = RactorRailsShim.storage[#{xp_key_str}] return v if RactorRailsShim.storage.key?(#{xp_key_str}) if Ractor.main? && instance_variable_defined?(:@x_forwarded_proto_priority) @x_forwarded_proto_priority else [:proto, :scheme] end end def x_forwarded_proto_priority=(val) RactorRailsShim.storage[#{xp_key_str}] = val @x_forwarded_proto_priority = val if Ractor.main? val end RUBY end |
._install_rack_utils_patch ⇒ Object
Patch Rack::Utils singleton attr_accessors (default_query_parser,
multipart_total_part_limit, multipart_file_limit) to not read @ivars
from a worker Ractor. The values are shareable once frozen (QueryParser,
Integers). Route through IES; workers read the shareable fallback.
default_query_parser is read per-request during POST parsing.
93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 |
# File 'lib/ractor_rails_shim/patches/rack.rb', line 93 def _install_rack_utils_patch return if @rack_utils_patched @rack_utils_patched = true _register_patch :rack_utils, "8.1" return unless defined?(::Rack::Utils) u = ::Rack::Utils dqp_key = :ractor_rails_shim_rack_utils_default_query_parser mtp_key = :ractor_rails_shim_rack_utils_multipart_total_part_limit mfl_key = :ractor_rails_shim_rack_utils_multipart_file_limit dqp_key_str = dqp_key.inspect mtp_key_str = mtp_key.inspect mfl_key_str = mfl_key.inspect u.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def default_query_parser v = RactorRailsShim.storage[#{dqp_key_str}] return v if RactorRailsShim.storage.key?(#{dqp_key_str}) if Ractor.main? && instance_variable_defined?(:@default_query_parser) v = @default_query_parser RactorRailsShim.storage[#{dqp_key_str}] = v v else RactorRailsShim::SHAREABLE_FALLBACK[#{dqp_key_str}] || ::Rack::QueryParser::QueryParser.make_default(32) end end def multipart_total_part_limit v = RactorRailsShim.storage[#{mtp_key_str}] return v if RactorRailsShim.storage.key?(#{mtp_key_str}) if Ractor.main? && instance_variable_defined?(:@multipart_total_part_limit) v = @multipart_total_part_limit RactorRailsShim.storage[#{mtp_key_str}] = v v else RactorRailsShim::SHAREABLE_FALLBACK[#{mtp_key_str}] || 128 end end def multipart_file_limit v = RactorRailsShim.storage[#{mfl_key_str}] return v if RactorRailsShim.storage.key?(#{mfl_key_str}) if Ractor.main? && instance_variable_defined?(:@multipart_file_limit) v = @multipart_file_limit RactorRailsShim.storage[#{mfl_key_str}] = v v else RactorRailsShim::SHAREABLE_FALLBACK[#{mfl_key_str}] || 64 end end RUBY CLASS_ATTRIBUTES << ["Rack::Utils", :default_query_parser, dqp_key, nil] CLASS_ATTRIBUTES << ["Rack::Utils", :multipart_total_part_limit, mtp_key, nil] CLASS_ATTRIBUTES << ["Rack::Utils", :multipart_file_limit, mfl_key, nil] end |
._install_reloader_patch ⇒ Object
Patch ActiveSupport::Reloader#check! / #reloaded!. These are CLASS
- methods that memoize
@should_reloadin a class ivar. ActionDispatch
Executor#call runs Reloader.run! -> check! on EVERY request, so a
worker Ractor writing that class ivar raises Ractor::IsolationError
("can not set instance variables of classes/modules by non-main
Ractors"). Route the flag through IsolatedExecutionState so each Ractor
has its own. With reloading disabled (config.enable_reloading = false,
the right setting for a frozen, shared kino :ractor graph) check.call is
lambda { false }, so workers compute false (no reload) — but the write
must still be Ractor-safe.
350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 |
# File 'lib/ractor_rails_shim/patches/active_support.rb', line 350 def _install_reloader_patch return if @reloader_patched @reloader_patched = true _register_patch :reloader, "8.1" return unless defined?(::ActiveSupport::Reloader) rl = ::ActiveSupport::Reloader key = :ractor_rails_shim_reloader_should_reload key_str = key.inspect rl.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def check! v = RactorRailsShim.storage[#{key_str}] return v if RactorRailsShim.storage.key?(#{key_str}) result = check.call RactorRailsShim.storage[#{key_str}] = result result end def reloaded! RactorRailsShim.storage[#{key_str}] = false end RUBY end |
._install_request_parameter_parsers_patch ⇒ Object
Patch ActionDispatch::Request.parameter_parsers (singleton attr_reader backed by @parameter_parsers) to not read the class ivar from a worker Ractor. The value is a Hash of MIME-type → parser (lambdas). Route through IES; workers read the shareable fallback (the boot-time parsers, made shareable). Read per-request during parameter parsing.
93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 |
# File 'lib/ractor_rails_shim/patches/action_dispatch.rb', line 93 def _install_request_parameter_parsers_patch return if @request_param_parsers_patched @request_param_parsers_patched = true _register_patch :request_parameter_parsers, "8.1" return unless defined?(::ActionDispatch::Request) req = ::ActionDispatch::Request pp_key = :ractor_rails_shim_request_parameter_parsers pp_key_str = pp_key.inspect req.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def parameter_parsers v = RactorRailsShim.storage[#{pp_key_str}] return v if RactorRailsShim.storage.key?(#{pp_key_str}) if Ractor.main? && instance_variable_defined?(:@parameter_parsers) v = @parameter_parsers RactorRailsShim.storage[#{pp_key_str}] = v v else RactorRailsShim::SHAREABLE_FALLBACK[#{pp_key_str}] || ActionDispatch::Request::DEFAULT_PARSERS end end RUBY CLASS_ATTRIBUTES << ["ActionDispatch::Request", :parameter_parsers, pp_key, nil] end |
._install_secure_random_alphabets! ⇒ Object
ActiveSupport core-ext defines SecureRandom::BASE36_ALPHABET /
BASE58_ALPHABET as Arrays of non-frozen Strings. These constants are
read by SecureRandom.base36 / base58, which ActiveStorage::Blob calls
to generate upload keys. A worker Ractor raises IsolationError accessing a
non-shareable constant, so deep-freeze them (making them shareable) in the
main Ractor. They are immutable by contract, so freezing is safe.
The core-ext is loaded lazily during boot. The constants are defined via
module-body assignment (BASE36_ALPHABET = (...)), which does NOT fire a
TracePoint(:constant) event, so we can't rely on the trace alone. Instead
we attempt the freeze at install time AND at prepare_for_ractors! time
(after initialize!, when the core-ext is guaranteed loaded). The
_freeze_secure_random_alphabets! helper is idempotent and skips already
shareable constants.
295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 |
# File 'lib/ractor_rails_shim/patches/active_storage.rb', line 295 def _install_secure_random_alphabets! _freeze_secure_random_alphabets! return if @_secure_random_tp return unless defined?(::SecureRandom) @_secure_random_tp = TracePoint.new(:constant) do |tp| name = tp.const_name next unless name == "SecureRandom::BASE36_ALPHABET" || name == "SecureRandom::BASE58_ALPHABET" _freeze_secure_random_alphabets! if %i[BASE36_ALPHABET BASE58_ALPHABET].all? do |c| ::SecureRandom.const_defined?(c) && ::Ractor.shareable?(::SecureRandom.const_get(c)) end @_secure_random_tp.disable @_secure_random_tp = nil end end @_secure_random_tp.enable rescue StandardError nil end |
._install_template_handlers_patch ⇒ Object
298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 |
# File 'lib/ractor_rails_shim/patches/action_view.rb', line 298 def _install_template_handlers_patch return if @template_handlers_patched @template_handlers_patched = true _register_patch :template_handlers, "8.1" return unless defined?(::ActionView::Template::Handlers) # Eager-load the handler classes in main so workers don't need to # autoload them (workers can't autoload). if Ractor.main? ::ActionView::Template::Handlers::Raw rescue nil ::ActionView::Template::Handlers::ERB rescue nil ::ActionView::Template::Handlers::Html rescue nil ::ActionView::Template::Handlers::Builder rescue nil end h = ::ActionView::Template::Handlers th_key = :ractor_rails_shim_av_template_handlers dth_key = :ractor_rails_shim_av_default_template_handlers th_key_str = th_key.inspect dth_key_str = dth_key.inspect # The handler registry lives in class variables (@@template_handlers, # @@default_template_handlers, @@template_extensions) whose values are # mutable Hashes holding handler instances — and an unshareable `:ruby` # lambda. A worker Ractor cannot read these class vars. Route the # registry through IsolatedExecutionState: each Ractor builds its own # handler map (the defaults are deterministic), and in main we seed from # the live class var (capturing any custom handlers gems registered at # boot). The `:ruby` lambda makes the map unshareable, so the old # SHAREABLE_FALLBACK approach (which skips unshareable values) left # workers with an empty map. These are instance methods (Handlers is # extended into ActionView::Template, and the render path calls them on # the Template instance), so they must be defined on the module itself, # not just the singleton class. h.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def self._ractor_rails_shim_handlers map = RactorRailsShim.storage[#{th_key_str}] return map unless map.nil? if Ractor.main? cv = class_variable_get(:@@template_handlers) rescue nil cv = nil if cv && cv.empty? if cv RactorRailsShim.storage[#{th_key_str}] = cv return cv end end built = { raw: ::ActionView::Template::Handlers::Raw.new, erb: ::ActionView::Template::Handlers::ERB.new, html: ::ActionView::Template::Handlers::Html.new, builder: ::ActionView::Template::Handlers::Builder.new, ruby: ->(_, source) { source }, } RactorRailsShim.storage[#{th_key_str}] = built built end def self._ractor_rails_shim_persist(map) RactorRailsShim.storage[#{th_key_str}] = map class_variable_set(:@@template_handlers, map) if Ractor.main? end def self.extensions self._ractor_rails_shim_handlers.keys end def registered_template_handler(extension) extension && ::ActionView::Template::Handlers._ractor_rails_shim_handlers[extension.to_sym] end def handler_for_extension(extension) registered_template_handler(extension) || ::ActionView::Template::Handlers::ERB.new end def template_handler_extensions ::ActionView::Template::Handlers._ractor_rails_shim_handlers.keys.map(&:to_s).sort end def register_template_handler(*extensions, handler) map = ::ActionView::Template::Handlers._ractor_rails_shim_handlers.dup extensions.each { |ext| map[ext.to_sym] = handler } ::ActionView::Template::Handlers._ractor_rails_shim_persist(map) end def unregister_template_handler(*extensions) map = ::ActionView::Template::Handlers._ractor_rails_shim_handlers.dup extensions.each { |ext| map.delete(ext.to_sym) } ::ActionView::Template::Handlers._ractor_rails_shim_persist(map) end def register_default_template_handler(extension, klass) register_template_handler(extension, klass) end RUBY end |
._install_warden_hooks_patch ⇒ Object
27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 |
# File 'lib/ractor_rails_shim/patches/warden.rb', line 27 def _install_warden_hooks_patch return if @warden_patched @warden_patched = true _register_patch :warden_hooks, "8.1" return unless defined?(::Warden::Hooks) ::Warden::Hooks.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def _after_set_user if Ractor.main? && instance_variable_defined?(:@_after_set_user) @_after_set_user else [] end end def _before_failure if Ractor.main? && instance_variable_defined?(:@_before_failure) @_before_failure else [] end end def _after_failed_fetch if Ractor.main? && instance_variable_defined?(:@_after_failed_fetch) @_after_failed_fetch else [] end end def _before_logout if Ractor.main? && instance_variable_defined?(:@_before_logout) @_before_logout else [] end end def _on_request if Ractor.main? && instance_variable_defined?(:@_on_request) @_on_request else [] end end RUBY end |
._install_warden_serializer_patch ⇒ Object
Warden registers per-scope session serializers with
Warden::SessionSerializer.send(:define_method, method_name, &block)
(warden-1.2.9/lib/warden/manager.rb:71). The block is created while the
app boots in the main Ractor, so the resulting method is Ractor-bound:
invoking it (e.g. user_serialize) from a worker Ractor raises
"defined with an un-shareable Proc in a different Ractor".
Devise's block body is simply mapping.to.serialize_into_session(record).
We re-register the serializers as plain def methods (which are NOT
Ractor-bound) that delegate to the model class's own
serialize_into_session / serialize_from_session class methods — both
worker-safe — so the chain is callable from any worker Ractor.
115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 |
# File 'lib/ractor_rails_shim/patches/warden.rb', line 115 def _install_warden_serializer_patch return if @warden_serializer_patched @warden_serializer_patched = true _register_patch :warden_serializer, "8.1" return unless defined?(::Warden::SessionSerializer) if defined?(::Devise) && ::Devise.respond_to?(:mappings) ::Devise.mappings.each do |scope, mapping| model = mapping.to ::Warden::SessionSerializer.class_eval <<-RUBY, __FILE__, __LINE__ + 1 def #{scope}_serialize(record) #{model}.serialize_into_session(record) end def #{scope}_deserialize(*keys) # Devise's serialize_into_session returns [[id], salt]. The key # passed by Warden::SessionSerializer#fetch is that value, but in # the kino :ractor worker the per-request session that Warden's # serializer sees can hold an extra wrapping layer # ([[[id], salt]]). Flatten so serialize_from_session(key, salt) # always receives exactly two arguments. #{model}.serialize_from_session(*keys.flatten) end RUBY end end ::Warden::SessionSerializer.class_eval do unless method_defined?(:serialize) def serialize(user) user end end unless method_defined?(:deserialize) def deserialize(key) key end end end end |
._install_warden_strategies_patch ⇒ Object
Patch Warden::Strategies#_strategies. The strategy registry is a lazy
class ivar (@strategies ||= {}) on the Warden::Strategies module; a
worker Ractor reading it raises "can not get unshareable values from
instance variables of classes/modules from non-main Ractors" (Devise's
current_user / user_signed_in? in a layout hits
Warden::Strategies -> _strategies). Capture the (shareable)
registry in main and expose it via a constant that workers read.
78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 |
# File 'lib/ractor_rails_shim/patches/warden.rb', line 78 def _install_warden_strategies_patch return if @warden_strategies_patched @warden_strategies_patched = true _register_patch :warden_strategies, "8.1" return unless defined?(::Warden::Strategies) ::Warden::Strategies.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def _strategies if Ractor.main? @strategies ||= {} else RactorRailsShim::SHAREABLE_WARDEN_STRATEGIES || {} end end RUBY if Ractor.main? begin strat = ::Warden::Strategies.instance_variable_get(:@strategies) strat = Ractor.make_shareable(strat) if strat && !Ractor.shareable?(strat) RactorRailsShim.const_set(:SHAREABLE_WARDEN_STRATEGIES, strat) unless RactorRailsShim.const_defined?(:SHAREABLE_WARDEN_STRATEGIES) rescue StandardError nil end end end |
._install_with_empty_template_cache_patch ⇒ Object
Patch ActionView::Base.with_empty_template_cache (action_view/base.rb:204)
to a block-free def. The original defines compiled_method_container
(instance + singleton) via define_method(&block) — an un-shareable Proc
compiled in the main Ractor that raises "defined with an un-shareable Proc
in a different Ractor" when a worker calls it. We also route compiled
template methods through ONE shared SHAREABLE_COMPILED_MODULE so the
application layout (and Devise shared partials) compile once and are
visible to every controller / worker Ractor.
Installed EARLY via ActiveSupport.on_load(:action_view) (see core.rb
install) so it is in place before production eager load calls
DetailsKey.view_context_class -> with_empty_template_cache. Idempotent.
43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 |
# File 'lib/ractor_rails_shim/patches/action_view.rb', line 43 def _install_with_empty_template_cache_patch return if @with_empty_template_cache_patched return unless defined?(::ActionView::Base) && Ractor.main? @with_empty_template_cache_patched = true _register_patch :with_empty_template_cache, "8.1" ::ActionView::Base.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def with_empty_template_cache subclass = Class.new(self) do include RactorRailsShim::SHAREABLE_COMPILED_MODULE def compiled_method_container RactorRailsShim::SHAREABLE_COMPILED_MODULE end def self.compiled_method_container RactorRailsShim::SHAREABLE_COMPILED_MODULE end def inspect "#<ActionView::Base:\#{'%#016x' % (object_id << 1)}>" end end subclass end RUBY end |
._make_mail_constants_shareable! ⇒ Object
Walk every module/class under Mail and make each un-shareable constant
into; leaf values (Regexp, Array, Hash, String, …) are frozen via
Ractor.make_shareable. Truly un-shareable values (e.g. Procs bound to the
main Ractor) are skipped — the worker would fall back to its own behavior.
This clears the mail gem's many Regexp module constants (e.g.
Mail::Utilities::TO_CRLF_REGEX), which would otherwise raise IsolationError
when read from a worker Ractor.
380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 |
# File 'lib/ractor_rails_shim/patches/mail.rb', line 380 def _make_mail_constants_shareable! root = ::Mail seen = {} walk = lambda do |mod| return if seen[mod.object_id] seen[mod.object_id] = true mod.constants(false).each do |cname| val = mod.const_get(cname) if val.is_a?(Module) walk.call(val) elsif !::Ractor.shareable?(val) begin ::Ractor.make_shareable(val) rescue StandardError nil end end rescue StandardError nil end end walk.call(root) rescue StandardError nil end |
._maybe_apply_active_storage_patch ⇒ Object
332 333 334 335 |
# File 'lib/ractor_rails_shim/patches/active_storage.rb', line 332 def _maybe_apply_active_storage_patch _apply_active_storage_macro_patch unless @_as_patched_macros _apply_active_storage_blob_patch unless @_as_patched_blob end |
._patch_active_model_type_default_value! ⇒ Object
717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 717 def _patch_active_model_type_default_value! return if @active_model_type_default_patched @active_model_type_default_patched = true _register_patch :active_model_type_default_value, "8.1" return unless defined?(::ActiveModel::Type::Value) return unless ::ActiveModel::Type.respond_to?(:default_value) # Warm the default in the main Ractor and build a shareable copy that # workers can read. Workers MUST NOT call _rrs_orig_default_value because # the original reads @default_value (a class ivar on ActiveModel::Type) # which is not accessible from non-main Ractors. Capture + shareable-copy # the template HERE (in main, at patch time) so the worker's patched # method never touches the raw ivar. main_default = ::ActiveModel::Type.default_value rescue nil return unless main_default # Build a shareable copy of the default_value now (in main) so workers # can read it without touching the class ivar. shareable_default = begin copy = main_default.dup Ractor.make_shareable(copy) rescue copy end ::ActiveModel::Type.singleton_class.class_eval do alias_method :_rrs_orig_default_value, :default_value def default_value if Ractor.main? _rrs_orig_default_value else RactorRailsShim.storage[:rrs_am_type_default_value] ||= ( RactorRailsShim::SHAREABLE_AM_TYPE_DEFAULT ) end end end # Store the shareable default as a constant so workers can read it # without touching @default_value on ActiveModel::Type. _reassign_shareable_const(:SHAREABLE_AM_TYPE_DEFAULT, shareable_default) end |
._patch_rails_module_body(mod) ⇒ Object
68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 |
# File 'lib/ractor_rails_shim/patches/rails_module.rb', line 68 def _patch_rails_module_body(mod) k = KEYS # Register the Rails module accessors in CLASS_ATTRIBUTES so the # shareable-fallback builder captures their main-ractor values at # prepare_for_ractors! / make_app_shareable! time and exposes them to # worker Ractors (e.g. Rails.logger is read per-request by # Rails::Rack::SilenceRequest). `application` is NOT registered — workers # get the shared app via Ractor.new(app), not via Rails.application. CLASS_ATTRIBUTES << ["Rails", :logger, k[:logger], nil] CLASS_ATTRIBUTES << ["Rails", :cache, k[:cache], nil] CLASS_ATTRIBUTES << ["Rails", :backtrace_cleaner, k[:backtrace_cleaner], nil] CLASS_ATTRIBUTES << ["Rails", :app_class, k[:app_class], nil] # We PREPEND a module onto Rails.singleton_class rather than redefine # the methods directly, because Rails defines its own `application`, # `env`, etc. LATER in rails.rb (`class << self; def application; ...`). # A direct module_eval redefinition would be clobbered when Rails' # own `def` runs afterward. A prepended module sits in front of the # singleton class in the method lookup chain and survives a later # `def` on the same class — so our IES-routed reader stays in front. # We call `super` to fall back to Rails' original method for the # main-ractor lazy-init path (which reads the @application ivar — # safe in the main ractor). patch = Module.new patch.module_eval <<-RUBY, __FILE__, __LINE__ + 1 # application: IES first; main ractor falls back to Rails' own # lazy init via super. Worker ractors return nil (their own IES # slot is empty until they boot their own app). def application v = RactorRailsShim.storage[#{k[:application].inspect}] return v if RactorRailsShim.storage.key?(#{k[:application].inspect}) if Ractor.main? super else RactorRailsShim::AppShareabilizer.const_defined?(:SHAREABLE_APP) ? RactorRailsShim::AppShareabilizer::SHAREABLE_APP : nil end end def application=(val) RactorRailsShim.storage[#{k[:application].inspect}] = val super if Ractor.main? val end # Simple accessors: app_class, cache, logger, backtrace_cleaner. # Workers fall back to the shareable fallback (built from main's # value at make_app_shareable! time) when their own IES slot is empty. def app_class v = RactorRailsShim.storage[#{k[:app_class].inspect}] return v if RactorRailsShim.storage.key?(#{k[:app_class].inspect}) return super if Ractor.main? RactorRailsShim::SHAREABLE_FALLBACK[#{k[:app_class].inspect}] end def app_class=(val) RactorRailsShim.storage[#{k[:app_class].inspect}] = val super if Ractor.main? val end def cache v = RactorRailsShim.storage[#{k[:cache].inspect}] return v if RactorRailsShim.storage.key?(#{k[:cache].inspect}) return super if Ractor.main? RactorRailsShim::SHAREABLE_FALLBACK[#{k[:cache].inspect}] end def cache=(val) RactorRailsShim.storage[#{k[:cache].inspect}] = val super if Ractor.main? val end def logger v = RactorRailsShim.storage[#{k[:logger].inspect}] return v if RactorRailsShim.storage.key?(#{k[:logger].inspect}) return super if Ractor.main? # Loggers are intrinsically mutable (formatters hold tag stacks, # logdevs hold IO + Mutex) and can't be shared read-only. Build a # per-worker ActiveSupport::BroadcastLogger (which mixes in # LoggerSilence#silence, used by Rails::Rack::SilenceRequest) # writing to $stderr (each Ractor has its own $stderr stream) and # cache it in IES so subsequent reads return the same instance. built = ActiveSupport::BroadcastLogger.new(Logger.new($stderr)) RactorRailsShim.storage[#{k[:logger].inspect}] = built built end def logger=(val) RactorRailsShim.storage[#{k[:logger].inspect}] = val super if Ractor.main? val end def backtrace_cleaner v = RactorRailsShim.storage[#{k[:backtrace_cleaner].inspect}] return v if RactorRailsShim.storage.key?(#{k[:backtrace_cleaner].inspect}) return super if Ractor.main? RactorRailsShim::SHAREABLE_FALLBACK[#{k[:backtrace_cleaner].inspect}] end def backtrace_cleaner=(val) RactorRailsShim.storage[#{k[:backtrace_cleaner].inspect}] = val super if Ractor.main? val end # env: worker ractors build their own EnvironmentInquirer from ENV # (no @ _env ivar to read). Main ractor falls back to super, which # lazily builds and caches in @_env. def env v = RactorRailsShim.storage[#{k[:env].inspect}] return v if RactorRailsShim.storage.key?(#{k[:env].inspect}) if Ractor.main? super else built = ActiveSupport::EnvironmentInquirer.new( ENV["RAILS_ENV"].presence || ENV["RACK_ENV"].presence || "development" ) RactorRailsShim.storage[#{k[:env].inspect}] = built built end end def env=(val) v = ActiveSupport::EnvironmentInquirer.new(val) RactorRailsShim.storage[#{k[:env].inspect}] = v super if Ractor.main? v end RUBY mod.singleton_class.prepend(patch) end |
._precompute_propshaft!(app) ⇒ Object
Warm every Propshaft Assembly/LoadPath lazy ivar in MAIN (before the app
is frozen). Warmed ivars are truthy, so the ||= memoization in workers
short-circuits instead of attempting to assign onto a frozen object.
Assets::by_path is itself memoized; warming assets populates it so
workers only read the frozen cache.
73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 |
# File 'lib/ractor_rails_shim/patches/propshaft.rb', line 73 def _precompute_propshaft!(app) return unless Ractor.main? assets = app.assets rescue nil return unless assets assets.load_path rescue nil assets.compilers rescue nil assets.resolver rescue nil assets.prefix rescue nil assets.processor rescue nil # Build the asset map (LoadPath#assets -> assets_by_path) so the inner # cache is populated and frozen before workers read it. Then warm each # Asset's lazy ivars (@content_type, @digest) in MAIN — they are memoized # via `||=` and the Asset objects live inside the frozen, shared app # graph, so a worker reading them would raise FrozenError. Warming here # (while still mutable) lets the `||=` short-circuit in workers. assets.load_path.assets.each do |asset| asset.content_type rescue nil asset.digest rescue nil end # Warm the resolver manifest cache. Propshaft::Resolver::Static#manifest # memoizes `@manifest ||= Propshaft::Manifest.from_path(...)`; the # resolver is frozen in the shared graph, so without warming, a worker # rendering a `stylesheet_link_tag` (or reading asset integrity) for a # PRECOMPILED manifest raises FrozenError ("can't modify frozen # Propshaft::Resolver::Static"). `#manifest` is private — invoke via # send to trigger the `||=` in MAIN (while still mutable) so workers only # read the frozen, cached value. resolver = assets.resolver rescue nil if resolver.respond_to?(:manifest, true) begin resolver.send(:manifest) rescue StandardError nil end end end |
._reassign_shareable_const(name, value) ⇒ Object
Reassign a constant on RactorRailsShim with a new shareable value, silencing the "already initialized constant" warning that const_set emits when the constant was previously defined. Centralizes the $VERBOSE-suppressed const_set dance that was repeated at every shareable-constant rebuild site (SHAREABLE_FALLBACK, SHAREABLE_MATTR_DEFAULTS, etc.). The value MUST already be frozen + shareable — this method does not make it so.
Issue #35 (Round 4): for the four frozen registries that Registry
owns (SHAREABLE_FALLBACK, SHAREABLE_MATTR_DEFAULTS,
ABSTRACT_REGISTRY, VIEW_CONTEXT_REGISTRY), also update the
Registry instance variable so role objects reading through
Registry.* see the new value. The facade const_set is kept for
the string-eval'd code that reads RactorRailsShim::SHAREABLE_*.
125 126 127 128 129 130 131 132 133 134 |
# File 'lib/ractor_rails_shim/patches/core.rb', line 125 def _reassign_shareable_const(name, value) ConstReassign.call(self, name, value) case name when :SHAREABLE_FALLBACK then Registry.reassign_shareable_fallback(value) when :SHAREABLE_MATTR_DEFAULTS then Registry.reassign_shareable_mattr_defaults(value) when :ABSTRACT_REGISTRY then Registry.reassign_abstract_registry(value) when :VIEW_CONTEXT_REGISTRY then Registry.reassign_view_context_registry(value) end value end |
._rebuild_activerecord_model_snapshots! ⇒ Object
(Re)build the per-model shareable snapshots that workers read instead of un-shareable class ivars: AR_PRIMARY_KEYS_SHAREABLE (model name -> primary key) and SHAREABLE_PENDING_ATTR_MODS (model object_id -> attribute macro modifications). Separated from _share_model_classes! so it can be called again AFTER eager-load (at make_app_shareable! time), when ActiveRecord::Base.descendants actually contains the app's models. Called too early (e.g. during the initial install, before eager-load) it only captures ActiveRecord::Base — the post-boot call overwrites the constant with the complete map, which is what workers spawned after make_app_shareable! read.
570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 570 def _rebuild_activerecord_model_snapshots! return unless defined?(::ActiveRecord::Base) classes = [::ActiveRecord::Base] classes.concat(::ActiveRecord::Base.descendants) rescue nil # pending attribute modifications (custom attribute macros) capture = {} classes.each do |klass| n = klass.name next unless n begin mods = klass.instance_variable_get(:@pending_attribute_modifications) if mods && mods.is_a?(Array) && !mods.empty? shareable = mods.dup Ractor.make_shareable(shareable) capture[klass.object_id] = shareable end rescue StandardError nil end end capture.freeze Ractor.make_shareable(capture) RactorRailsShim._reassign_shareable_const(:SHAREABLE_PENDING_ATTR_MODS, capture) # primary keys pk_map = {} classes.each do |klass| n = klass.name next unless n pk = klass.primary_key rescue next pk_map[n] = pk if pk end shareable = Ractor.make_shareable(pk_map) _reassign_shareable_const(:AR_PRIMARY_KEYS_SHAREABLE, shareable) end |
._register_for_fallback(mod_name, sym, key, default) ⇒ Object
Register a mattr accessor so _build_shareable_fallback! can capture + make shareable the main-ractor value at prepare_for_ractors! time. The default is stored too so the fallback builder can use it when the live value can't be shared (e.g. __callbacks holds self-capturing Procs).
245 246 247 |
# File 'lib/ractor_rails_shim/patches/mattr_accessor.rb', line 245 def _register_for_fallback(mod_name, sym, key, default) CLASS_ATTRIBUTES << [mod_name, sym, key, default] end |
._register_patch(name, *tested_segments) ⇒ Object
Record that a patch was developed/tested against the given Rails version segments. Delegates to RactorRailsShim::VersionPolicy.register.
277 278 279 |
# File 'lib/ractor_rails_shim/patches/core.rb', line 277 def _register_patch(name, *tested_segments) RactorRailsShim::VersionPolicy.register(name, *tested_segments) end |
._seed_active_storage_prefix! ⇒ Object
Seed the shareable ActiveStorage table-name prefix/suffix constants from
the (main-ractor) ActiveStorage.table_name_prefix value. Must run in the
main Ractor at prepare_for_ractors! time, after ActiveStorage is fully
loaded. Workers read these constants (never the un-shareable mattr reader).
418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 |
# File 'lib/ractor_rails_shim/patches/active_storage.rb', line 418 def _seed_active_storage_prefix! return unless defined?(::ActiveStorage) && ::ActiveStorage.respond_to?(:table_name_prefix) # The reader was redefined to return the shareable constant, so read the # ORIGINAL value from `ActiveStorage`'s `@@table_name_prefix` class # variable (the mattr_accessor store) in main — callable from the main # Ractor, unlike the un-shareable mattr reader from a worker. prefix = if ::ActiveStorage.class_variable_defined?(:@@table_name_prefix) ::ActiveStorage.class_variable_get(:@@table_name_prefix).to_s else "active_storage_" end suffix = if ::ActiveStorage.class_variable_defined?(:@@table_name_suffix) ::ActiveStorage.class_variable_get(:@@table_name_suffix).to_s else "" end RactorRailsShim.const_set(:ACTIVE_STORAGE_PREFIX, prefix.freeze) RactorRailsShim.const_set(:ACTIVE_STORAGE_SUFFIX, suffix.freeze) # Reset the cached `table_name` on ActiveStorage models so they recompute # with the correct prefix/suffix. Without this, `ActiveStorage::Blob`'s # `table_name` stays `"blobs"` (computed at eager-load with the empty # prefix) instead of `"active_storage_blobs"`. Set explicitly to avoid # going through `reset_table_name` (which may fail in edge cases). if defined?(::ActiveStorage::Blob) ::ActiveStorage::Blob.table_name = "#{prefix}blobs#{suffix}" end if defined?(::ActiveStorage::Attachment) ::ActiveStorage::Attachment.table_name = "#{prefix}attachments#{suffix}" end end |
._seed_mattr_default(key, default) ⇒ Object
Store the definition-time default for a mattr accessor in the runtime registry. If the default is shareable, also rebuild the SHAREABLE_MATTR_DEFAULTS constant (frozen + made shareable) so worker Ractors can read it before prepare_for_ractors! runs. The constant reassignment goes through _reassign_shareable_const (centralized $VERBOSE suppression).
INVARIANT: this method MUST only be called pre-spawn (i.e. during boot
in the main Ractor, before worker Ractors are forked). The rebuild-
from-scratch pattern (dup, freeze, make_shareable, const_set)
reassigns the SHAREABLE_MATTR_DEFAULTS constant; any holder of a stale
reference (e.g. a worker that already read the constant) won't see the
update. The codebase is careful to call this only at mattr-definition
time (boot), but a future maintainer adding a post-spawn call would
silently break workers that cached the old constant. Don't.
230 231 232 233 234 235 236 237 238 239 |
# File 'lib/ractor_rails_shim/patches/mattr_accessor.rb', line 230 def _seed_mattr_default(key, default) MATTR_DEFAULTS[key] = default if default && Ractor.shareable?(default) h = SHAREABLE_MATTR_DEFAULTS.dup h[key] = default h.freeze Ractor.make_shareable(h) _reassign_shareable_const(:SHAREABLE_MATTR_DEFAULTS, h) end end |
._share_active_record_internals! ⇒ Object
ActiveRecord's internal helper classes (the *Clause classes used while
building a relation's Arel) cache a frozen "empty" singleton in a class
instance variable via @empty ||= new(...).freeze (e.g.
ActiveRecord::Relation::WhereClause#empty). Reading that class ivar from
a worker Ractor raises Ractor::IsolationError if the value isn't
shareable. Fix: warm .empty in the main Ractor (populating @empty with
its frozen singleton), then make every class ivar on these helper
classes shareable. Idempotent; must run in the main Ractor.
ActiveRecord's internal helper classes/modules hold class instance
variables that a worker Ractor reads during query building / connection
establishment (e.g. ActiveRecord::ConnectionAdapters.@adapters — a Hash
of adapter_name => [class_name, path]; the *Clause classes' @empty
frozen singleton). A class ivar whose VALUE is shareable IS readable from
a worker Ractor (unlike class variables), so we deep-freeze each value
in the main Ractor and write it back (Monitor/Mutex->NoOpLock,
Concurrent::Map->frozen Hash, etc. via _shareable_ivar_replacement).
TARGETED: only specific leaf registries (not a broad ActiveRecord::* sweep). A broad sweep also freezes AR-railtie initializer Collections reachable from the app graph, which breaks make_app_shareable's proc-replacement (frozen containers can't be mutated to swap Procs).
659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 659 def _share_active_record_internals! return unless defined?(::ActiveRecord::Base) # 1. Warm + freeze the *Clause `.empty` singletons. ObjectSpace.each_object(Class) do |c| n = c.name next unless n && n.start_with?("ActiveRecord::Relation::") && n.end_with?("Clause") begin c.empty if c.respond_to?(:empty) rescue StandardError => e end _freeze_class_ivars!(c) end # 2. ConnectionAdapters.@adapters (String => [class_name, path]) — read # by `ConnectionAdapters.resolve` during establish_connection. if defined?(::ActiveRecord::ConnectionAdapters) _freeze_class_ivars!(::ActiveRecord::ConnectionAdapters) end # 3. ActiveRecord::Type — holds @default_value (a lazy singleton Value # used as a fallback type). Warm it and freeze the class ivar. if defined?(::ActiveRecord::Type) ::ActiveRecord::Type.default_value rescue nil _freeze_class_ivars!(::ActiveRecord::Type) end # 4. ActiveModel::Type — holds @default_value and @registry (contains # Procs). Can't freeze the class ivars (Registry has Procs). Route # default_value through IsolatedExecutionState so worker Ractors get # their own lazily-initialized copy. if defined?(::ActiveModel::Type) _patch_active_model_type_default_value! end # 5. ActiveRecord::Associations::AssociationScope::INSTANCE is an instance # singleton wrapping an unshareable identity lambda. Worker Ractors # read it on EVERY association scope build (AssociationScope.scope), # so it must be Ractor-shareable. Replace the constant with a # deep-frozen, shareable copy built in the main Ractor — the same # pattern as _freeze_journey_visitors! for Journey's *::INSTANCE. if defined?(::ActiveRecord::Associations::AssociationScope) && ::ActiveRecord::Associations::AssociationScope.const_defined?(:INSTANCE) inst = ::ActiveRecord::Associations::AssociationScope::INSTANCE unless Ractor.shareable?(inst) _swallow("make AssociationScope::INSTANCE shareable") do # Reassigning a constant that already exists warns ("already # initialized constant"); drop the old binding first. ::ActiveRecord::Associations::AssociationScope.send(:remove_const, :INSTANCE) if ::ActiveRecord::Associations::AssociationScope.const_defined?(:INSTANCE, false) ::ActiveRecord::Associations::AssociationScope.const_set( :INSTANCE, Ractor.make_shareable(inst) ) end end end end |
._share_model_classes! ⇒ Object
ActiveRecord model classes lazily initialize many class instance
variables on first use (e.g. @table_name, @arel_table, @predicate_builder,
@columns_hash, @attribute_methods_module) via @ivar ||= compute. The
computation is deterministic, but it WRITES the class ivar — which a
worker Ractor cannot do (Ractor::IsolationError: "can not set instance
variables of classes/modules by non-main Ractors").
Fix: in the main Ractor, warm every model class by running representative
queries (count / first / page), which populates all the lazy class ivars
with their shareable-or-not values. Then make every class ivar's VALUE
shareable (deep-freeze via Ractor.make_shareable) and write it back while
the class is still mutable in main. A class ivar holding a shareable value
is readable from a worker Ractor, and the worker's ||= short-circuits
(no write). Idempotent; must run in the main Ractor after eager_load.
404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 404 def _share_model_classes! return unless defined?(::ActiveRecord::Base) # Suppress reload_schema_from_cache for the rest of the main Ractor's # lifecycle. During warming, abstract classes (ActiveRecord::Base, # ApplicationRecord) trigger reload_schema_from_cache which recursively # resets @schema_loaded = false and @columns_hash = nil on all # descendants, destroying schema data that workers need. After # make_app_shareable! freezes the graph, no more reloads should happen # in main anyway. RactorRailsShim.instance_variable_set(:@_rrs_schema_warming, true) # Force load_schema on all concrete models BEFORE freezing any # reflections. load_schema! writes to reflection objects (e.g. # CounterCache writes @counter_cache_column), so it must run before # _share_model_classes! freezes them via Ractor.make_shareable. classes = [::ActiveRecord::Base] classes.concat(::ActiveRecord::Base.descendants) rescue nil classes.each do |klass| next if klass.respond_to?(:abstract_class?) && klass.abstract_class? begin klass.send(:load_schema) if klass.respond_to?(:load_schema, true) rescue StandardError nil end end classes = [::ActiveRecord::Base] classes.concat(::ActiveRecord::Base.descendants) rescue nil classes.each do |klass| # Warm the class's lazy ivars by actually exercising the query paths # the workers will hit. Main has a working connection handler, so this # populates exactly the ivars a real query touches. # Warm the class's lazy ivars by actually exercising the query paths # the workers will hit. Main has a working connection handler, so this # populates exactly the ivars a real query touches. Each call is # isolated: a failure in one must not skip the rest (e.g. the private # `relation` method or a cold connection must not prevent `table_name` # from being set). warm_calls = [ -> { klass.connection_pool if klass.respond_to?(:connection_pool) }, -> { klass.send(:reset_primary_key) if klass.respond_to?(:reset_primary_key, true) }, -> { klass.table_name }, -> { klass.arel_table }, -> { klass.columns_hash }, -> { klass.attribute_names }, -> { klass.attribute_types }, -> { klass.predicate_builder }, -> { klass.defined_enums if klass.respond_to?(:defined_enums) }, -> { klass.send(:relation) if klass.respond_to?(:relation, true) }, -> { klass.count rescue nil }, -> { klass.first rescue nil }, -> { klass.send(:query_constraints_list) if klass.respond_to?(:query_constraints_list, true) }, -> { if defined?(::Kaminari) && klass.respond_to?(:page) klass.page(1).to_a rescue nil end }, ] warm_calls.each { |c| begin; c.call; rescue StandardError => e; end } # Warm reflection objects. Reflections are part of the model class # graph and get deep-frozen by make_app_shareable!. Their lazy caches # (@class_name, @klass, @inverse_name, @scope, etc.) memoize via ||= # which writes to a frozen object from a worker -> FrozenError. Warm # them here in main so the frozen copies hold the values. begin if klass.respond_to?(:reflect_on_all_associations, true) klass.reflect_on_all_associations.each do |refl| begin refl.class_name rescue nil refl.klass rescue nil refl.inverse_name rescue nil refl.inverse_of rescue nil refl.active_record rescue nil refl.plural_name rescue nil refl. rescue nil refl.macro rescue nil refl.scope rescue nil if refl.respond_to?(:scope) refl.check_validity! rescue nil refl.automatic_inverse_of rescue nil if refl.respond_to?(:automatic_inverse_of) rescue StandardError nil end end end rescue StandardError nil end # Make every class ivar shareable and write it back. The class is # still mutable here (in main), so the write is allowed. begin klass.instance_variables.each do |iv| v = klass.instance_variable_get(iv) rescue nil next unless v next if Ractor.shareable?(v) replacement = _shareable_ivar_replacement(v) next unless replacement begin klass.instance_variable_set(iv, replacement) rescue StandardError => e # frozen owner — leave as-is end end rescue StandardError => e # BasicObject / frozen owners end end # Build the per-model shareable snapshots (primary keys + pending # attribute modifications) workers read instead of un-shareable class # ivars. Delegated to _rebuild_activerecord_model_snapshots! so it can # be called again post-eager-load (at make_app_shareable! time) once # ActiveRecord::Base.descendants holds the app's models. _capture_dependent_associations! _rebuild_activerecord_model_snapshots! end |
._share_relation_delegate_caches! ⇒ Object
Make every loaded AR model class's @relation_delegate_cache shareable. Idempotent; must run in the main Ractor after eager_load so that all model classes (and their caches) exist.
371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 371 def _share_relation_delegate_caches! return unless defined?(::ActiveRecord::Base) classes = [::ActiveRecord::Base] classes.concat(::ActiveRecord::Base.descendants) rescue nil classes.each do |klass| cache = klass.instance_variable_get(:@relation_delegate_cache) rescue nil next unless cache next if Ractor.shareable?(cache) begin klass.instance_variable_set(:@relation_delegate_cache, Ractor.make_shareable(cache)) rescue StandardError => e # Best-effort: if a cache holds an unshareable delegate class we # can't freeze, skip it. The worker will then hit a clear error on # the first relation method and we can patch that class specifically. end end end |
._shareable_ivar_replacement(v) ⇒ Object
- Monitor/Mutex -> NoOpLock (never contended post-boot)
- Concurrent::Map -> frozen Hash
- else -> Ractor.make_shareable; if that fails (statement caches etc.), a frozen empty container of the same kind so the worker reads a shareable value (cold cache in workers; slower, correct). Returns nil if no replacement can be made.
614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 614 def _shareable_ivar_replacement(v) if v.is_a?(::Monitor) || v.is_a?(::Mutex) Ractor.make_shareable(NoOpLock.new) elsif defined?(::Concurrent::Map) && v.is_a?(::Concurrent::Map) h = {} begin v.each_pair { |k, val| h[k] = val } rescue StandardError => e end Ractor.make_shareable(h) else begin Ractor.make_shareable(v) rescue StandardError => e case v when ::Hash then Ractor.make_shareable({}) when ::Array then Ractor.make_shareable([]) when ::Set then Ractor.make_shareable(::Set.new) else nil end end end end |
._swallow(label) ⇒ Object
Swallow an exception raised by the block, optionally logging it when
debug? is on. Used by freeze/shareability paths where individual
failures are expected (some ivars hold intrinsically unshareable
values like Procs) but a worker crash on the same value later has no
visible cause. label identifies the call site (e.g. "freeze AR ivar
Post@column_defaults"). Keep the label short — it's only for grepping.
Delegates to RactorRailsShim::Funnel.swallow (extracted Issue #31,
step 31.1d).
107 108 109 |
# File 'lib/ractor_rails_shim/patches/core.rb', line 107 def _swallow(label) Funnel.swallow(label) { yield } end |
._version_mismatch(message) ⇒ Object
Apply the configured policy to a mismatch message. Delegates to RactorRailsShim::VersionPolicy.mismatch.
263 264 265 |
# File 'lib/ractor_rails_shim/patches/core.rb', line 263 def _version_mismatch() RactorRailsShim::VersionPolicy.mismatch() end |
._warm_journey_routes! ⇒ Object
Warm + cache @ast / @simulator on the live Routes graph. Called from
make_app_shareable! AFTER the route precompute (which resets the routes)
and BEFORE Ractor.make_shareable freezes the graph. Must run in the main
Ractor. Idempotent (caches on the mutable object, then frozen in place).
740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 |
# File 'lib/ractor_rails_shim/patches/action_dispatch.rb', line 740 def _warm_journey_routes! return unless Ractor.main? _freeze_journey_visitors! _freeze_mime_negotiation! begin rs = ::Rails.application.routes rescue nil # Navigate to the ActionDispatch::Journey::Routes object — the one that # is frozen into the shared graph and read (via Router#simulator) on # every request. Rails wraps it in a RouteSet (and sometimes a # LazyRouteSet), neither of which defines #simulator, so calling # `rs.routes.simulator` would silently NoMethodError and leave @simulator # uncached — forcing worker Ractors to rebuild the simulator (and hit # Ractor::IsolationError on GTG constants). Descend through #routes until # we reach the Journey::Routes instance. routes = rs while routes.respond_to?(:routes) && !routes.is_a?(::ActionDispatch::Journey::Routes) nxt = routes.routes break if nxt.equal?(routes) routes = nxt end if routes.is_a?(::ActionDispatch::Journey::Routes) routes.ast routes.simulator _warm_path_patterns!(routes) end rescue StandardError => e # best-effort end end |
._warm_path_patterns!(routes) ⇒ Object
Pre-compute every Journey::Path::Pattern's lazy memoized ivars
(@required_names, @optional_names, @offsets, @re,
@requirements_for_missing_keys_check) on the LIVE (unfrozen) pattern,
before Ractor.make_shareable freezes the graph. Path::Pattern#match
(called on every request during route recognition) memoizes @offsets
via @offsets ||= ...; on a frozen pattern that write raises
FrozenError. By computing it now (and caching into the frozen object),
the worker reads the cached value and never writes. We deliberately do
NOT call the built-in eager_load!, which sets @ast = nil (the
@ast/@spec are still read by requirements_anchored? and must survive).
718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 |
# File 'lib/ractor_rails_shim/patches/action_dispatch.rb', line 718 def _warm_path_patterns!(routes) return unless routes.respond_to?(:routes) routes.routes.each do |r| p = r.respond_to?(:path) ? r.path : nil next unless p begin p.required_names p.optional_names p.send(:offsets) p.to_regexp p.requirements_for_missing_keys_check if p.respond_to?(:requirements_for_missing_keys_check) rescue StandardError # best-effort — a pattern we can't warm will fall back to its own # (non-frozen) copy if one exists; ignore unusual shapes. end end end |
.applicable_patches ⇒ Object
Report which registered patches apply to the runtime Rails version (and which were skipped because they're untested on it). Useful for diagnostics and CI. Returns a Hash: { applied: [...], skipped: [...] }. Delegates to RactorRailsShim::VersionPolicy.applicable.
271 272 273 |
# File 'lib/ractor_rails_shim/patches/core.rb', line 271 def applicable_patches RactorRailsShim::VersionPolicy.applicable end |
.autoload_install! ⇒ Object
Require this gem and the patches auto-install IF Rails is loaded.
If Rails isn't loaded yet, install is deferred to the first call
of install (call it from config/boot.rb before Rails.application).
24 25 26 |
# File 'lib/ractor_rails_shim.rb', line 24 def autoload_install! install if defined?(::Rails) && !installed? end |
.capture_app_constants! ⇒ Object
Capture a frozen name -> object map for every constant the application's Zeitwerk loaders manage. Runs in the MAIN Ractor, after eager load, where all app constants are defined. The map travels to worker Ractors, which use it to (re)bind the constant names into their own namespaces.
Why this is needed: a Ractor boundary does NOT share top-level constant
names — only the class/module objects reachable from the frozen shared
app graph cross the boundary. A worker Ractor therefore sees
RactorRailsShim, ActiveRecord, ApplicationRecord, the controllers,
etc. (objects reachable from the app), but NOT the application's own
model constants (e.g. Post): the object is in the graph, but its name
is not bound in the worker, so PostsController#index's Post reference
raises NameError. Rebinding the captured names fixes it without
re-running autoloading (which is itself impossible in a worker, since
Zeitwerk::Loader.new raises IsolationError off the main Ractor).
Capture a frozen name -> object map for every constant the
application's Zeitwerk loaders manage. Delegates to
WorkerAppFactory.capture_constants! (extracted Issue #13, Step 13.4;
WorkerApp moved to ractor_rails_shim/worker_app.rb). See
WorkerAppFactory for the contract (Zeitwerk introspection, the
non-Zeitwerk guard, and why the captured map is needed for worker
constant rebinding).
303 304 305 |
# File 'lib/ractor_rails_shim/patches/core.rb', line 303 def capture_app_constants! WorkerAppFactory.capture_constants! end |
.debug=(value) ⇒ Object
95 96 97 |
# File 'lib/ractor_rails_shim/patches/core.rb', line 95 def debug=(value) Funnel.debug = value end |
.debug? ⇒ Boolean
When true, swallowed exceptions in freeze/shareability paths are reported to $stderr so a worker Ractor that later crashes on an unshareable value has a traceable cause. Default false (silent, the historical behaviour). Enable for diagnosis:
RactorRailsShim.debug = true
Delegates to RactorRailsShim::Funnel (extracted Issue #31, step 31.1d).
91 92 93 |
# File 'lib/ractor_rails_shim/patches/core.rb', line 91 def debug? Funnel.debug? end |
.fix_url_helpers_singleton_routes! ⇒ Object
The module-level singleton _routes (set via redefine_singleton_method
in the gem's included block) is still a define_method block. It is not
on the request hot-path, but make it shareable anyway. Called after the
module has been included into ActionView/ActionController (i.e. from
prepare_for_ractors!).
136 137 138 139 140 141 142 143 144 145 146 147 |
# File 'lib/ractor_rails_shim/patches/route_helpers.rb', line 136 def fix_url_helpers_singleton_routes! return unless defined?(::Rails) && ::Rails.application return unless ::Rails.application.routes.respond_to?(:url_helpers) mod = ::Rails.application.routes.url_helpers return unless mod.respond_to?(:singleton_class) mod.singleton_class.class_eval do def _routes ::Rails.application.routes end end rescue StandardError end |
.init_worker_ar_connections! ⇒ Object
Worker-Ractor hook: create a fresh ConnectionHandler and establish connections from the captured configurations snapshot. Call this in each worker Ractor before serving requests:
Ractor.new(app) do |a|
RactorRailsShim.init_worker_ar_connections!
a.call(env)
end
Idempotent: safe to call multiple times (subsequent calls are no-ops once the handler is established). Uses Ractor.store_if_absent semantics via IES.
236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 236 def init_worker_ar_connections! return if Ractor.main? return unless defined?(::ActiveRecord::Base) key = :active_record_connection_handler # Store the handler in Ractor-local storage (Ractor.current), NOT in # the IES-backed RactorRailsShim.storage. IES is per-THREAD (it is a # Thread.attr_accessor), so a value set on the init thread is invisible # to the other worker threads in the same worker Ractor -> # ConnectionNotEstablished ("No connection handler for Ractor X"). # Ractor.current storage is per-Ractor and shared by every thread of the # worker, so connection_handler resolves the same handler for all threads. existing = Ractor.current[key] return if existing # Establish a fresh, per-Ractor connection handler + pool from the # captured configurations snapshot. We call ConnectionHandler#establish_connection # DIRECTLY (not ActiveRecord::Base.establish_connection, which writes # the `@resolved_config` class ivar from the worker -> IsolationError). # ConnectionHandler#establish_connection reads ActiveRecord::Base.configurations # (now IES-routed by _install_activerecord_configurations_patch) inside # resolve_pool_config, so it works from a worker. Best-effort per config. snapshot = AR_CONFIGURATIONS_SNAPSHOT if snapshot && !snapshot.empty? env = ENV["RAILS_ENV"].presence || ENV["RACK_ENV"].presence || "development" env_configs = snapshot[env] || snapshot.values.first || {} handler = ::ActiveRecord::ConnectionAdapters::ConnectionHandler.new env_configs.each do |_name, config| begin handler.establish_connection(config, owner_name: ::ActiveRecord::Base, role: ::ActiveRecord::Base.current_role || :writing, shard: ::ActiveRecord::Base.current_shard || :default) rescue StandardError => e # Best-effort: if one connection fails, continue with others. end end Ractor.current[key] = handler end end |
.install ⇒ Object
Install all the patches. Safe to call multiple times (idempotent). Delegates to Installer.install (extracted Issue #13, Step 13.6). See Installer for the orchestration contract (version check, run-mode resolve, branch by mode, early-boot install_* calls).
183 184 185 |
# File 'lib/ractor_rails_shim/patches/core.rb', line 183 def install Installer.install end |
.install_class_attribute ⇒ Object
33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 |
# File 'lib/ractor_rails_shim/patches/class_attribute.rb', line 33 def install_class_attribute _register_patch :class_attribute, "8.1" return if @class_attr_patched @class_attr_patched = true if defined?(::ActiveSupport::ClassAttribute) patch_class_attribute! else # Defer until ActiveSupport::ClassAttribute loads. A TracePoint(:class) # fires when `module ClassAttribute` opens. One-shot. @ca_tp = TracePoint.new(:class) do |trace| if defined?(::ActiveSupport::ClassAttribute) && !@ca_patched @ca_tp.disable patch_class_attribute! end end @ca_tp.enable end end |
.install_execution_wrapper ⇒ Object
17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
# File 'lib/ractor_rails_shim/patches/execution_wrapper.rb', line 17 def install_execution_wrapper return if @exec_wrapper_patched @exec_wrapper_patched = true _register_patch :execution_wrapper, "8.1" if defined?(::ActiveSupport::ExecutionWrapper) patch_execution_wrapper! else @ew_tp = TracePoint.new(:class) do |trace| if defined?(::ActiveSupport::ExecutionWrapper) && !@exec_wrapper_registry_patched @ew_tp.disable patch_execution_wrapper! end end @ew_tp.enable end end |
.install_mattr_accessor ⇒ Object
9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 |
# File 'lib/ractor_rails_shim/patches/mattr_accessor.rb', line 9 def install_mattr_accessor _register_patch :mattr_accessor, "8.1" return if @mattr_patched @mattr_patched = true ::Module.prepend(Module.new { # The prepended module's body is evaluated in the main ractor at # prepend time; the methods it defines are callable from any ractor # because they're defined via string eval (no captured binding). # But mattr_accessor itself runs at app boot in the main ractor, and # the per-accessor redefinition must also use string eval. # # IMPORTANT: Rails' mattr_accessor/cattr_accessor stores values in # CLASS VARIABLES (@@sym), not class instance variables (@sym). The # default value is written via class_variable_set("@@sym", default). # Class variables are also subject to Ractor::IsolationError from # non-main ractors (verified on Ruby 4.0.5), so we route through IES # the same way — but the main-ractor fallback must read @@sym, and # the seed must run in the main ractor at define-time (via super). def mattr_accessor(*syms, instance_reader: true, instance_writer: true, instance_accessor: true, default: nil, **kwargs, &block) shareable = kwargs[:shareable] mod_name = name # Compute the default value the same way Rails does, so we can # seed worker-ractor IES slots with it (workers can't read @@sym). # The block form is evaluated once here (in main ractor) like Rails. sym_default = block_given? && default.nil? ? yield : default super # define the methods via the original path (sets @@sym) syms.each do |sym| key = :"ractor_rails_shim_mattr_#{mod_name}_#{sym}" key_str = key.inspect cv = "@@#{sym}" cv_str = cv.inspect # Register so _build_shareable_fallback! can capture the main-ractor # value (read from @@sym) at prepare_for_ractors! time. The default # is stored too so the fallback builder can use it when the live # value can't be shared. RactorRailsShim._register_for_fallback(mod_name, sym, key, sym_default) # Store the default in a runtime registry (NOT inlined into the # eval'd method body — arbitrary objects like Logger have invalid # `.inspect` output). The reader looks it up by key. Also rebuilds # the shareable subset constant if the default is shareable. RactorRailsShim._seed_mattr_default(key, sym_default) # Redefine the class reader via string eval (no captured binding). # Class variables are only touched from the main ractor; worker # ractors fall back to SHAREABLE_FALLBACK (built from main's @@sym # at prepare_for_ractors! time) when their own IES slot is empty. # NOTE: we deliberately do NOT inline the default value here — # arbitrary objects (e.g. Logger) have invalid `.inspect` output. # The fallback builder captures the live value (which may equal # the default) at prepare time. singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def #{sym} v = RactorRailsShim.storage[#{key_str}] return v if RactorRailsShim.storage.key?(#{key_str}) if #{!!shareable} if class_variable_defined?(#{cv_str}) class_variable_get(#{cv_str}) end elsif Ractor.main? if class_variable_defined?(#{cv_str}) class_variable_get(#{cv_str}) else nil end else # Worker: try the shareable fallback (built from main's @@sym # at prepare_for_ractors! time). If empty, try the # definition-time default (only the shareable subset — the # full MATTR_DEFAULTS holds unshareable defaults like Logger # which workers can't read via the constant). fb = RactorRailsShim::SHAREABLE_FALLBACK[#{key_str}] return fb unless fb.nil? RactorRailsShim::SHAREABLE_MATTR_DEFAULTS[#{key_str}] end end def #{sym}=(val) RactorRailsShim.storage[#{key_str}] = val class_variable_set(#{cv_str}, val) if Ractor.main? val end RUBY # Instance readers/writers route through IES directly (NOT # self.class.#{sym}). Rails' original uses @@sym (a class variable # inherited by including classes); the shim routes through IES, # so the instance reader must also use IES. Using self.class.sym # would fail for mattr_accessor on Modules (e.g. # ActionView::Helpers::FormHelper#form_with_generates_ids): # self.class is the including class (ActionView::Base), which # doesn't have the module's singleton method. # Only redefine if instance_accessor is on (matches Rails). if instance_reader && instance_accessor module_eval <<-RUBY, __FILE__, __LINE__ + 1 def #{sym} v = RactorRailsShim.storage[#{key_str}] return v if RactorRailsShim.storage.key?(#{key_str}) if Ractor.main? self.class.class_variable_defined?(#{cv_str}) ? self.class.class_variable_get(#{cv_str}) : nil else RactorRailsShim::SHAREABLE_FALLBACK[#{key_str}] end end RUBY end if instance_writer && instance_accessor module_eval <<-RUBY, __FILE__, __LINE__ + 1 def #{sym}=(val) RactorRailsShim.storage[#{key_str}] = val self.class.class_variable_set(#{cv_str}, val) if Ractor.main? && self.class.class_variable_defined?(#{cv_str}) val end RUBY end end end # mattr_reader declares read-only class/module attributes that Rails # generates as `def self.<sym>; @@<sym>; end`. The raw `@@<sym>` class # variable is unreadable from a non-main Ractor, raising # Ractor::IsolationError. Route the reader through IES (mirroring # mattr_accessor) so workers fall back to SHAREABLE_FALLBACK (built from # the main-ractor `@@<sym>` at prepare_for_ractors! time). There is no # writer, so only the reader is redefined. ActiveRecord::Encryption uses # `mattr_reader :config` for its encryption Config — without this patch # every worker hits the class-variable IsolationError when loading a # model's schema. def mattr_reader(*syms, instance_reader: true, instance_accessor: true, default: nil, location: nil) mod_name = name sym_default = block_given? && default.nil? ? yield : default super # defines the original `def self.<sym>; @@<sym>; end` readers # and seeds @@<sym> with sym_default (via class_variable_set). syms.each do |sym| key = :"ractor_rails_shim_mattr_#{mod_name}_#{sym}" key_str = key.inspect cv = "@@#{sym}" cv_str = cv.inspect RactorRailsShim._register_for_fallback(mod_name, sym, key, sym_default) singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def #{sym} v = RactorRailsShim.storage[#{key_str}] return v if RactorRailsShim.storage.key?(#{key_str}) if Ractor.main? if class_variable_defined?(#{cv_str}) class_variable_get(#{cv_str}) else nil end else fb = RactorRailsShim::SHAREABLE_FALLBACK[#{key_str}] return fb unless fb.nil? RactorRailsShim::SHAREABLE_MATTR_DEFAULTS[#{key_str}] end end RUBY if instance_reader && instance_accessor module_eval <<-RUBY, __FILE__, __LINE__ + 1 def #{sym} v = RactorRailsShim.storage[#{key_str}] return v if RactorRailsShim.storage.key?(#{key_str}) if Ractor.main? self.class.class_variable_defined?(#{cv_str}) ? self.class.class_variable_get(#{cv_str}) : nil else RactorRailsShim::SHAREABLE_FALLBACK[#{key_str}] end end RUBY end end end # cattr_accessor is an alias for mattr_accessor in Rails; route it too. # Define the alias only when it isn't already present (Rails defines it # as `alias_method :cattr_accessor, :mattr_accessor`, which already # routes to the prepended mattr_accessor). Defining it ourselves when # missing keeps cattr_accessor working in environments (e.g. a bare # test suite) where Rails' core_ext hasn't defined it yet. unless method_defined?(:cattr_accessor, true) def cattr_accessor(*args, **kwargs, &block) mattr_accessor(*args, **kwargs, &block) end end # cattr_reader is an alias for mattr_reader in Rails; route it too. # Same logic as cattr_accessor above. unless method_defined?(:cattr_reader, true) def cattr_reader(*args, **kwargs, &block) mattr_reader(*args, **kwargs, &block) end end }) end |
.install_rails_load_hook ⇒ Object
Defer the Rails-module patch until Rails is defined. A TracePoint on
:class fires when rails.rb opens module Rails (module bodies fire
as :class); once the constant is assigned, we patch once and disable
the hook.
Two flags are intentional and guard different things:
@rails_load_hook_installed — the TracePoint one-shot is armed (so we
don't stack multiple TracePoints on repeated `install` calls).
@rails_module_patched — the patch itself has been applied (checked
again inside the TracePoint block and in `patch_rails_module!`
because the immediate-path caller also goes through that method).
46 47 48 49 50 51 52 53 54 55 56 57 |
# File 'lib/ractor_rails_shim/patches/rails_module.rb', line 46 def install_rails_load_hook return if @rails_load_hook_installed @rails_load_hook_installed = true @rails_tp = TracePoint.new(:class) do |trace| if defined?(::Rails) && !@rails_module_patched @rails_tp.disable patch_rails_module!(::Rails) end end @rails_tp.enable end |
.install_rails_module ⇒ Object
Patch the Rails module's class-level accessors (Rails.application, Rails.env, Rails.cache, etc.) to route through IsolatedExecutionState. Defers via a load hook if Rails isn't defined yet (the config/boot.rb case).
26 27 28 29 30 31 32 33 |
# File 'lib/ractor_rails_shim/patches/rails_module.rb', line 26 def install_rails_module _register_patch :rails_module, "8.1" if defined?(::Rails) patch_rails_module!(::Rails) else install_rails_load_hook end end |
.install_route_helpers_patch ⇒ Object
30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 |
# File 'lib/ractor_rails_shim/patches/route_helpers.rb', line 30 def install_route_helpers_patch return if @route_helpers_patched @route_helpers_patched = true _register_patch :route_helpers, "8.1" return unless defined?(::ActionDispatch::Routing::RouteSet) rs = ::ActionDispatch::Routing::RouteSet # 1. Replace the PATH / UNKNOWN lambdas with shareable Method objects. begin rs.send(:remove_const, :PATH) if rs.const_defined?(:PATH, false) rescue StandardError end rs.const_set(:PATH, ::ActionDispatch::Http::URL.method(:path_for)) rescue nil begin rs.send(:remove_const, :UNKNOWN) if rs.const_defined?(:UNKNOWN, false) rescue StandardError end rs.const_set(:UNKNOWN, ::ActionDispatch::Http::URL.method(:url_for)) rescue nil # 2. Named route helpers -> compiled `def` (regular `resources` routes). rs.class_eval <<-RUBY, __FILE__, __LINE__ + 1 def define_url_helper(mod, name, helper, url_strategy) const_name = :"RRS_HELPER_\#{name}" # Reassigning a constant that already exists warns ("already # initialized constant"); drop the old binding first so re-draws of # the same route stay silent. mod.send(:remove_const, const_name) if mod.const_defined?(const_name, false) mod.const_set(const_name, helper) strategy = url_strategy.equal?(PATH) ? :PATH : :UNKNOWN body = "def " + name.to_s + "(*args)\\n" \ " last = args.last\\n" \ " options = case last\\n" \ " when Hash then args.pop\\n" \ " when ActionController::Parameters then args.pop.to_h\\n" \ " end\\n" \ " ::ActionDispatch::Routing::RouteSet.const_get(" + const_name.inspect + ").call(self, " + name.inspect + ", args, options, ::ActionDispatch::Routing::RouteSet.const_get(" + strategy.inspect + "))\\n" \ "end" mod.module_eval(body, __FILE__, __LINE__ + 1) end RUBY # 3. `direct` / `resolve` helpers -> compiled `def`. if defined?(::ActionDispatch::Routing::RouteSet::NamedRouteCollection) ::ActionDispatch::Routing::RouteSet::NamedRouteCollection.class_eval <<-RUBY, __FILE__, __LINE__ + 1 def add_url_helper(name, defaults, &block) helper = ::ActionDispatch::Routing::RouteSet::CustomUrlHelper.new(name, defaults, &block) path_name = :"\#{name}_path" url_name = :"\#{name}_url" # Reassigning a constant that already exists warns ("already # initialized constant"); drop the old binding first so re-draws of # the same helper stay silent. @path_helpers_module.send(:remove_const, :"RRS_HELPER_\#{path_name}") if @path_helpers_module.const_defined?(:"RRS_HELPER_\#{path_name}", false) @path_helpers_module.const_set(:"RRS_HELPER_\#{path_name}", helper) @url_helpers_module.send(:remove_const, :"RRS_HELPER_\#{url_name}") if @url_helpers_module.const_defined?(:"RRS_HELPER_\#{url_name}", false) @url_helpers_module.const_set(:"RRS_HELPER_\#{url_name}", helper) pbody = "def " + path_name.to_s + "(*args)\\n const_get(:\\\"RRS_HELPER_" + path_name.to_s + "\\\").call(self, args, true)\\nend" ubody = "def " + url_name.to_s + "(*args)\\n const_get(:\\\"RRS_HELPER_" + url_name.to_s + "\\\").call(self, args, false)\\nend" @path_helpers_module.module_eval(pbody, __FILE__, __LINE__ + 1) @url_helpers_module.module_eval(ubody, __FILE__, __LINE__ + 1) @path_helpers << path_name @url_helpers << url_name self end RUBY end # 4. `_routes` / `_generate_paths_by_default` -> compiled `def`. rs.class_eval <<-RUBY, __FILE__, __LINE__ + 1 alias_method :_rrs_orig_generate_url_helpers, :generate_url_helpers def generate_url_helpers(supports_path) routes = self mod = _rrs_orig_generate_url_helpers(supports_path) mod.module_eval("def _routes\n @_routes || ::Rails.application.routes\nend", __FILE__, __LINE__ + 1) mod.module_eval("def _generate_paths_by_default\n " + supports_path.inspect + "\nend", __FILE__, __LINE__ + 1) # Bound the stock `self.included` reinclude (actionpack # action_dispatch/routing/route_set.rb). That hook re-dups the module # and re-includes it while `!base._routes.equal?(@_proxy._routes)`. # Under the frozen, Ractor-shareable app graph a worker Ractor's # controller can report `_routes` as nil, so the equality never holds # and the reinclude loops forever (SystemStackError on the very first # request, e.g. GET /up -> Rails::HealthController). The intent is to # re-align `_routes` per base exactly once; bound it to one reinclude # per base so the loop can never happen while preserving that intent. seen = ::Set.new mod.instance_variable_set(:@_rrs_reinclude_seen, seen) mod.singleton_class.class_eval do alias_method :_rrs_orig_self_included, :included def included(base) seen = instance_variable_get(:@_rrs_reinclude_seen) return if seen.include?(base.object_id) seen << base.object_id _rrs_orig_self_included(base) end end mod end RUBY end |
.install_rubygems ⇒ Object
Installed at install (boot) time. Only redefines the reader; the
snapshot is filled in later by snapshot_gem_paths!.
21 22 23 24 25 26 27 |
# File 'lib/ractor_rails_shim/patches/rubygems.rb', line 21 def install_rubygems return if @rubygems_patched @rubygems_patched = true _register_patch :rubygems, "all" return unless defined?(::Gem) patch_rubygems! end |
.install_url_helpers_patch ⇒ Object
27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 |
# File 'lib/ractor_rails_shim/patches/url_helpers.rb', line 27 def install_url_helpers_patch return if @url_helpers_patched @url_helpers_patched = true _register_patch :url_helpers, "8.1" return unless defined?(::ActiveRecord::Base) if defined?(::ActionView::RoutingUrlFor) ::ActionView::RoutingUrlFor.module_eval <<-RUBY, __FILE__, __LINE__ + 1 alias_method :_rrs_orig_view_url_for, :url_for def url_for(options = nil) _rrs_orig_view_url_for(options) rescue StandardError => e raise unless e.message.include?("un-shareable Proc") if options.is_a?(Hash) || options.is_a?(ActionController::Parameters) full_url_for(options) else meth = _generate_paths_by_default ? :path : :url builder = ActionDispatch::Routing::PolymorphicRoutes::HelperMethodBuilder.public_send(meth) builder.handle_model_call(self, options) end end RUBY end if defined?(::ActionDispatch::Routing::UrlFor) ::ActionDispatch::Routing::UrlFor.module_eval <<-RUBY, __FILE__, __LINE__ + 1 alias_method :_rrs_orig_full_url_for, :full_url_for def full_url_for(options = nil) _rrs_orig_full_url_for(options) rescue StandardError => e raise unless e.message.include?("un-shareable Proc") if options.is_a?(Hash) || options.is_a?(ActionController::Parameters) route_name = options.delete :use_route merged = options.to_h.symbolize_keys.reverse_merge!(url_options) # `self._routes` is an un-shareable `define_method` block in a # worker Ractor, so calling it re-raises the same error. Route the # fallback through the worker-safe shareable RouteSet instead. if RactorRailsShim.const_defined?(:SHAREABLE_ROUTES) RactorRailsShim::SHAREABLE_ROUTES.url_for(merged, route_name) else _routes.url_for(merged, route_name) end else builder = ActionDispatch::Routing::PolymorphicRoutes::HelperMethodBuilder.url builder.handle_model_call(self, options) end end RUBY end end |
.install_zeitwerk_registry ⇒ Object
17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
# File 'lib/ractor_rails_shim/patches/zeitwerk_registry.rb', line 17 def install_zeitwerk_registry return if @zeitwerk_patched @zeitwerk_patched = true _register_patch :zeitwerk_registry, "8.1" if defined?(::Zeitwerk::Registry) patch_zeitwerk_registry! else # Defer until Zeitwerk loads. A TracePoint(:class) fires when # `module Registry` opens. One-shot. # # Two flags guard different things (mirrors `rails_module.rb`): # @zeitwerk_patched — `install_zeitwerk_registry` ran # (so we don't arm a second TracePoint). # @zeitwerk_registry_patched — the actual patch was applied (checked # again inside the TracePoint block and # in `patch_zeitwerk_registry!`). @zw_tp = TracePoint.new(:class) do |trace| if defined?(::Zeitwerk::Registry) && !@zeitwerk_registry_patched @zw_tp.disable patch_zeitwerk_registry! end end @zw_tp.enable end end |
.installed? ⇒ Boolean
187 188 189 |
# File 'lib/ractor_rails_shim/patches/core.rb', line 187 def installed? Installer.installed? end |
.make_app_shareable!(app) ⇒ Object
Public API: make Rails.application shareable across Ractors. Delegates
to RactorRailsShim::AppShareabilizer.make_shareable!(app) (extracted
Step 22.6, Issue #22). See AppShareabilizer for the full pipeline
contract (precompute → freeze ivars → warm routes → neutralize logger
→ replace procs → replace locks → make_shareable → build fallback).
WARNING: this MUTATES the app object graph in place (replaces ivars). The app becomes read-only (frozen). Do NOT call if you intend to keep mutating the app (e.g. development reloading). Production-only.
Returns the shareable app. Raises on failure (e.g. if a Proc can't be replaced — add the missing constant to shareable_constants first).
52 53 54 |
# File 'lib/ractor_rails_shim/patches/make_shareable.rb', line 52 def make_app_shareable!(app) AppShareabilizer.make_shareable!(app) end |
.make_constant_shareable!(const_path) ⇒ Object
Resolve a constant path string to a value, and if it exists and is not already shareable, replace it with its shareable (deep-frozen) version. Returns true if the constant was made shareable (or already was); false if it doesn't exist yet (caller may retry). Delegates to ConstantShareabilizer.make_shareable! (extracted Issue #13, Step 13.1).
212 213 214 |
# File 'lib/ractor_rails_shim/patches/core.rb', line 212 def make_constant_shareable!(const_path) ConstantShareabilizer.make_shareable!(const_path) end |
.patch_class_attribute! ⇒ Object
The actual patch. Idempotent. Must run in the main Ractor.
redefine is a singleton method on ClassAttribute (defined in
class << self), so we prepend onto the singleton class.
55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 |
# File 'lib/ractor_rails_shim/patches/class_attribute.rb', line 55 def patch_class_attribute! return if @ca_patched @ca_patched = true ::ActiveSupport::ClassAttribute.singleton_class.prepend(Module.new { # redefine is called once per attribute at class_attribute-definition # time (in the main Ractor). The original defines methods with blocks; # we replace with string-eval'd methods that route through IES so # they're callable from any Ractor. The default value is seeded into # the main Ractor's IES slot immediately (matching original semantics # where the reader returns the default until a subclass overrides). def redefine(owner, name, namespaced_name, value) key = :"ractor_rails_shim_class_attr_#{owner.object_id}_#{namespaced_name}" # Seed the main Ractor's IES slot with the default. Only seed in # main — workers start nil and set their own value via the writer. RactorRailsShim.storage[key] = value if Ractor.main? # Also store in CLASS_ATTR_VALUES so the reader can fall back to it # in the MAIN ractor on non-boot threads. IES is thread-local: Puma's # request threads have empty IES slots, so the reader returns nil # without this fallback. This is the bug that breaks normal (non- # Ractor) multi-threaded servers — the minimal --minimal app didn't # hit it because /up doesn't trigger LogSubscriber.log_levels. # CLASS_ATTR_VALUES is NOT shareable (values may be mutable); only # safe to read from the main ractor. RactorRailsShim::Registry.class_attr_values[key] = value # Register so _build_shareable_fallback! can capture + make shareable # at prepare_for_ractors! time. owner.name may be nil for anonymous # classes (e.g. spec fixtures); use a stable label in that case. # The default value is stored too so the fallback builder can use it # when the live value can't be made shareable (e.g. __callbacks holds # self-capturing Procs — workers get the empty default, treating # boot-time callbacks as already-run, which is correct for a frozen # shared app). owner_label = owner.respond_to?(:name) ? owner.name : owner.class.name owner_label = owner_label || "anon_#{owner.class.name}_#{owner.object_id}" RactorRailsShim::Registry.class_attributes << [owner_label, namespaced_name, key, value] # Always define the namespaced reader/writer on owner's singleton # class via string eval (no captured binding). The class_attribute # macro itself also defines `def #{name}; #{namespaced_name}; end` # via class_eval (string-eval'd, safe) on the owner — that calls our # IES-routed namespaced reader/writer. We override BOTH the namespaced # and (when owner is a module's singleton) the public name. # # Worker-Ractor fallback: when the worker's own IES slot is empty # (which it is by default — the value lives in main's slot), fall # back to the frozen shareable table built at prepare_for_ractors! # time. This is read-only and shared across all workers; workers that # need their own mutable value call the writer, which writes their # IES slot and shadows the fallback. target = owner.singleton_class? ? owner : owner.singleton_class # Static missing-slot default: frozen shared Hash for __callbacks # (Rails indexes the result, so nil would NoMethodError), nil for # everything else. Decided once at method-definition time. missing_default = (name == :__callbacks) ? "RactorRailsShim::EMPTY_CALLBACKS_HASH" : "nil" # ONE heredoc for both modes — the selected strategy # (RactorRailsShim.storage_strategy, set once at install from # RunMode.thread?) decides the lookup/store backend. Collapses the # former two-mode `if thread_mode?` branch (Issue #15). target.module_eval RactorRailsShim._class_attr_methods(namespaced_name, namespaced_name, missing_default), __FILE__, __LINE__ + 1 # When owner is a module's singleton class, the original also # defines a public reader `def #{name}` on owner directly. Override # it with the strategy-routed version. if owner.singleton_class? && owner.attached_object.is_a?(Module) owner.module_eval RactorRailsShim._class_attr_methods(name, namespaced_name, missing_default), __FILE__, __LINE__ + 1 end # When owner IS a singleton class (e.g. called from class << self), # class_attribute's class_eval does `class << self` which opens a # nested singleton class. The public `def #{name}` ends up on # owner.singleton_class, which calls `#{namespaced_name}` — but # that method was only defined on `target` (= owner, the singleton # class), not on the nested level. Define it on owner.singleton_class # too so the nested `def #{name}` can resolve it. if owner.singleton_class? owner.singleton_class.module_eval RactorRailsShim._class_attr_methods(namespaced_name, namespaced_name, missing_default), __FILE__, __LINE__ + 1 end end # redefine_method is used by `redefine` internally and by other call # sites (rare). The class_attribute path goes through our `redefine` # above; keep the original block-based behavior for any other callers # so we don't break unrelated code. def redefine_method(owner, name, private: false, &block) super end }) end |
.patch_execution_wrapper! ⇒ Object
34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 |
# File 'lib/ractor_rails_shim/patches/execution_wrapper.rb', line 34 def patch_execution_wrapper! return if @exec_wrapper_registry_patched @exec_wrapper_registry_patched = true ew = ::ActiveSupport::ExecutionWrapper key = :ractor_rails_shim_exec_wrapper_active_key key_str = key.inspect # active_key returns :"active_execution_wrapper_<object_id>"; a frozen # Symbol is shareable. Compute it once per Ractor and cache in IES. ew.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def active_key v = RactorRailsShim.storage[#{key_str}] return v if RactorRailsShim.storage.key?(#{key_str}) sym = :"active_execution_wrapper_\#{object_id}" RactorRailsShim.storage[#{key_str}] = sym sym end RUBY # Patch ActiveSupport::Callbacks#run_callbacks to tolerate a nil # __callbacks (the case in worker Ractors whose class_attribute fallback # couldn't be made shareable because callback chains hold frozen, # self-capturing Procs). For a frozen, read-only shared app the boot-time # callbacks (ExecutionContext push/pop, CurrentAttributes clear) already # ran in the main Ractor at boot; worker Ractors don't need to re-run # them per request (CurrentAttributes/ExecutionContext are thread-local, # hence per-Ractor, and start empty in a fresh worker). When __callbacks # is nil, run_callbacks just yields the block — matching the empty-chain # fast path in the original. (Method body lives in active_support.rb.) _install_callbacks_nil_safe_patch # Patch ActiveSupport::Notifications.notifier to not read the @notifier # class ivar from a worker Ractor. The original is `attr_accessor # :notifier` with `@notifier = Fanout.new` set at module load — a raw # class ivar holding a Fanout (which has a Mutex + subscriber Procs, # both unshareable). Workers get their own per-Ractor Fanout (no # subscribers — instrumentation is a no-op in workers, which is correct # for a read-only shared app where log subscribers already ran in main). # `notifier` is read by `instrumenter` (per-request via Rails::Rack::Logger). # (Method body lives in active_support.rb.) _install_notifications_notifier_patch end |
.patch_rails_module!(mod) ⇒ Object
The actual Rails-module patch. Idempotent. Must be called from the main Ractor (it prepends onto Rails.singleton_class).
61 62 63 64 65 66 |
# File 'lib/ractor_rails_shim/patches/rails_module.rb', line 61 def patch_rails_module!(mod) return if @rails_module_patched @rails_module_patched = true RactorRailsShim::ConstantShareabilizer.apply! _patch_rails_module_body(mod) end |
.patch_rubygems! ⇒ Object
29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 |
# File 'lib/ractor_rails_shim/patches/rubygems.rb', line 29 def patch_rubygems! return if @rubygems_method_patched @rubygems_method_patched = true gem = ::Gem unless gem.singleton_class.method_defined?(:__shim_original_gem_paths) gem.singleton_class.alias_method :__shim_original_gem_paths, :paths end # `def` (not `define_method`) so the method has no captured binding and # is callable from any Ractor. `Ractor.main?` + a shareable constant are # both Ractor-safe. gem.singleton_class.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def paths if Ractor.main? __shim_original_gem_paths else ::RactorRailsShim::GEM_PATHS_SNAPSHOT end end RUBY end |
.patch_zeitwerk_registry! ⇒ Object
43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 |
# File 'lib/ractor_rails_shim/patches/zeitwerk_registry.rb', line 43 def patch_zeitwerk_registry! return if @zeitwerk_registry_patched @zeitwerk_registry_patched = true reg = ::Zeitwerk::Registry # The ivars Zeitwerk sets at the bottom of registry.rb. Map each to an # IES key and a default-builder string (eval'd in the reader when the # Ractor's slot is empty). Builders reference Zeitwerk constants by # full path so they're resolvable from any Ractor. ivars = { loaders: [:ractor_rails_shim_zw_loaders, "Zeitwerk::Registry::Loaders.new"], gem_loaders_by_root_file: [:ractor_rails_shim_zw_gem, "{}"], autoloads: [:ractor_rails_shim_zw_autoloads, "Zeitwerk::Registry::Autoloads.new"], explicit_namespaces: [:ractor_rails_shim_zw_explicit, "Zeitwerk::Registry::ExplicitNamespaces.new"], inceptions: [:ractor_rails_shim_zw_inceptions, "Zeitwerk::Registry::Inceptions.new"], mutex: [:ractor_rails_shim_zw_mutex, "Mutex.new"], } # Redefine each reader (and the mutex, which is read directly as @mutex) # to route through IES with lazy per-Ractor init. Use a PREPENDED module # (not direct module_eval on the singleton class) because Zeitwerk's # `attr_reader :loaders` etc. run LATER in the module body and would # clobber a direct redefinition — same load-order issue as the Rails # module accessors. A prepended module stays in front of the lookup chain. # # In the MAIN ractor, fall back to the existing ivar (set by Zeitwerk at # the bottom of registry.rb) so main-ractor state is preserved. Worker # ractors lazily build their own via the builder string. reader_patch = Module.new ivars.each do |ivar, (key, builder)| key_str = key.inspect ivar_sym = :"@#{ivar}" ivar_str = ivar_sym.inspect reader_patch.module_eval <<-RUBY, __FILE__, __LINE__ + 1 def #{ivar} v = RactorRailsShim.storage[#{key_str}] return v if RactorRailsShim.storage.key?(#{key_str}) if Ractor.main? existing = instance_variable_get(#{ivar_str}) if instance_variable_defined?(#{ivar_str}) if existing RactorRailsShim.storage[#{key_str}] = existing return existing end end v = #{builder} RactorRailsShim.storage[#{key_str}] = v v end RUBY end reg.singleton_class.prepend(reader_patch) # `conflicting_root_dir?` and `loader_for_gem` read @mutex / @gem_loaders # directly via instance_variable_get-ish access (they use @mutex in the # method body). Since we redefined the readers, the direct @mutex refs # in those methods still hit the ivar. We need to rewrite those two # methods to call the reader instead. Easiest: prepend a module that # calls self.mutex / self.gem_loaders_by_root_file. reg.singleton_class.prepend(Module.new { def conflicting_root_dir?(loader, new_root_dir) mutex.synchronize do loaders.each do |existing_loader| next if existing_loader == loader existing_loader.__roots.each_key do |existing_root_dir| next if !new_root_dir.start_with?(existing_root_dir) && !existing_root_dir.start_with?(new_root_dir) new_root_dir_slash = new_root_dir + '/' existing_root_dir_slash = existing_root_dir + '/' next if !new_root_dir_slash.start_with?(existing_root_dir_slash) && !existing_root_dir_slash.start_with?(new_root_dir_slash) next if loader.__ignores?(existing_root_dir) break if existing_loader.__ignores?(new_root_dir) return existing_loader end end nil end end def loader_for_gem(root_file, namespace:, warn_on_extra_files:) h = gem_loaders_by_root_file h[root_file] ||= Zeitwerk::GemLoader.__new(root_file, namespace: namespace, warn_on_extra_files: warn_on_extra_files) end def unregister_loader(loader) gem_loaders_by_root_file.delete_if { |_, l| l == loader } end }) end |
.prepare_for_ractors! ⇒ Object
Public API: run after Rails.application.initialize! and BEFORE spawning
worker Ractors. Makes every registered constant shareable (deep-freeze).
Constants that didn't exist at install time (e.g. Rails::Railtie, loaded
after module Rails opens) get fixed here. Idempotent; safe to call
multiple times. Must run in the main Ractor.
NOTE: this does NOT build the framework-config shareable fallback. That
step is folded into make_app_shareable! because some class_attribute /
mattr_accessor values reference the app graph, and making them shareable
must happen AFTER the app itself is already frozen (otherwise the app
gets frozen prematurely and precompute/proc-replacement can't mutate
it). If you call prepare_for_ractors! standalone (without
make_app_shareable!), worker Ractors will see nil for framework config
values that couldn't be shared without freezing the app — set them
explicitly per worker, or use make_app_shareable!.
Delegates to Lifecycle.prepare_for_ractors! (extracted POODR §1 SRP).
240 241 242 |
# File 'lib/ractor_rails_shim/patches/core.rb', line 240 def prepare_for_ractors! Lifecycle.prepare_for_ractors! end |
.shareable_constants ⇒ Object
Public reader for the SHAREABLE_CONSTANTS registry. Users register
their own constants via RactorRailsShim.shareable_constants << "MyGem::LIST".
Delegates to ConstantShareabilizer.shareable_constants (which reads
RactorRailsShim::SHAREABLE_CONSTANTS).
203 204 205 |
# File 'lib/ractor_rails_shim/patches/core.rb', line 203 def shareable_constants ConstantShareabilizer.shareable_constants end |
.snapshot_gem_paths! ⇒ Object
Called from prepare_for_ractors! (post-boot, main Ractor) so the snapshot reflects Bundler-configured gem paths. Also called from install as a safety net so the constant is never undefined.
53 54 55 56 57 58 59 60 61 62 63 |
# File 'lib/ractor_rails_shim/patches/rubygems.rb', line 53 def snapshot_gem_paths! return unless defined?(::Gem) return if defined?(::RactorRailsShim::GEM_PATHS_SNAPSHOT) snap = ::Gem.paths begin Ractor.make_shareable(snap) rescue StandardError snap = Ractor.make_shareable(::Gem.path) end ::RactorRailsShim.const_set(:GEM_PATHS_SNAPSHOT, snap) end |
.snapshot_query_logs! ⇒ Object
Capture the QueryLogs handlers/formatter as a shareable snapshot for workers (called post-boot, main Ractor, in prepare_for_ractors!).
We deliberately do NOT use Ractor.make_shareable on the raw @handlers
objects: a handler may be a ZeroArityHandler wrapping a Proc, a raw
lambda/Proc tag, or an IdentityHandler whose value is unshareable — all of
which make_shareable raises on, which (the original rescue swallowed) left
QUERY_LOGS_SNAPSHOT unset and every worker falling through to the original
tag_content -> @handlers read -> Ractor::IsolationError. Instead we build a
fresh, guaranteed-shareable structure:
{ format: :legacy|:sqlcommenter,
handlers: [[key, :get_key, nil] | [key, :identity, value], ...] }
Only GetKeyHandler (context key lookup) and IdentityHandler (constant value, when that value itself is shareable) are captured. Proc/lambda handlers can't be expressed cross-Ractor and are dropped in workers (tags that depend on per-request Procs simply don't appear in worker query comments — acceptable; the main Ractor still logs them).
2142 2143 2144 2145 2146 2147 2148 2149 2150 2151 2152 2153 2154 2155 2156 2157 2158 2159 2160 2161 2162 2163 2164 2165 2166 2167 2168 2169 2170 2171 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 2142 def snapshot_query_logs! return unless defined?(::ActiveRecord::QueryLogs) return if RactorRailsShim.const_defined?(:QUERY_LOGS_SNAPSHOT) return unless Ractor.main? begin ql = ::ActiveRecord::QueryLogs format = ql. format = :legacy if format == false || format.nil? raw_handlers = ql.instance_variable_get(:@handlers) || [] entries = [] raw_handlers.each do |key, handler| if handler.is_a?(::ActiveRecord::QueryLogs::GetKeyHandler) entries << [key, :get_key, nil] elsif handler.is_a?(::ActiveRecord::QueryLogs::IdentityHandler) value = handler.instance_variable_get(:@value) next unless Ractor.shareable?(value) entries << [key, :identity, value] else # ZeroArityHandler (wraps a Proc) or a raw Proc/lambda tag: # intrinsically unshareable / depends on a closure; skip in workers. next end end snap = { format: format, handlers: entries.freeze }.freeze Ractor.make_shareable(snap) RactorRailsShim.const_set(:QUERY_LOGS_SNAPSHOT, snap) rescue StandardError nil end end |
.storage ⇒ Object
The active storage implementation. Patch sites route through this so the shim doesn't open the ActiveSupport namespace to alias the fallback.
95 96 97 |
# File 'lib/ractor_rails_shim/foundation/storage.rb', line 95 def storage Storage.storage end |
.thread_mode=(value) ⇒ Object
172 173 174 175 176 177 |
# File 'lib/ractor_rails_shim/patches/core.rb', line 172 def thread_mode=(value) RactorRailsShim::RunMode.thread = value # `storage_strategy` derives lazily from `RunMode.thread?` (see # `StorageStrategy`), so no explicit sync here — resetting `RunMode` # is enough to reset the strategy. end |
.thread_mode? ⇒ Boolean
Install all the patches. Safe to call multiple times (idempotent).
May be called either before or after Rails is loaded:
- If Rails is already defined (e.g. `Bundler.require` ran first), the
Rails module accessors are patched immediately.
- If Rails is not yet defined (the normal `config/boot.rb` case, where
`install` is called before `require "rails"`), a one-shot load hook
defers the Rails-module patch until `rails.rb` is loaded. The
`mattr_accessor` macro patch (a `Module.prepend`) applies
immediately regardless, because it patches the macro itself, not
any Rails constant.
True when the shim should install its THREAD-server (Puma/Falcon) mode instead of the default Ractor (kino) mode. In thread mode Ractor.main? is true, so Rails' own globals (class variables / class ivars) are thread-safe and used as-is; only the class_attribute callback-chain isolation fix and the nil-safe callback replay are installed. The other patches route framework globals through per-Ractor IsolatedExecutionState, which is empty on Puma's request threads and would break the app, so they are skipped.
Configuration is owned by RactorRailsShim::RunMode: set explicitly via
RactorRailsShim.thread_mode = true (or RunMode.thread = true), or
let install resolve it from ENV["SERVER"]
(puma|falcon|thin|webrick|thread*). The decision is a configuration
responsibility extracted from install per POODR; install calls
RunMode.resolve! (a no-op when already configured explicitly) and then
reads RunMode.thread?. These facade methods delegate for backward
compatibility with existing call sites (patches/class_attribute.rb,
patches/active_support.rb, specs).
168 169 170 |
# File 'lib/ractor_rails_shim/patches/core.rb', line 168 def thread_mode? RactorRailsShim::RunMode.thread? end |
.version_policy ⇒ Object
Policy for version mismatches. One of :warn (default), :strict, :off.
Set before install:
RactorRailsShim.version_policy = :strict
Delegates to RactorRailsShim::VersionPolicy.policy.
76 77 78 |
# File 'lib/ractor_rails_shim/patches/core.rb', line 76 def version_policy RactorRailsShim::VersionPolicy.policy end |
.version_policy=(value) ⇒ Object
80 81 82 |
# File 'lib/ractor_rails_shim/patches/core.rb', line 80 def version_policy=(value) RactorRailsShim::VersionPolicy.policy = value end |
.worker_app!(frozen_app) ⇒ Object
Build the shareable Rack app handed to kino. Delegates to WorkerAppFactory.build (extracted Issue #13, Step 13.4). See WorkerAppFactory for the shareability contract (returns a frozen, Ractor.shareable? WorkerApp instance).
311 312 313 |
# File 'lib/ractor_rails_shim/patches/core.rb', line 311 def worker_app!(frozen_app) WorkerAppFactory.build(frozen_app) end |
.worker_ar_init(app) ⇒ Object
Wrap app so every worker Ractor initializes its ActiveRecord
connections on first request. Returns a shareable wrapper.
2076 2077 2078 |
# File 'lib/ractor_rails_shim/patches/activerecord.rb', line 2076 def worker_ar_init(app) ArWorkerInitWrapper.new(app) end |