product_tours 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8c3fecc3261d2acf6fb9267139897e7e0c4c6fa9dd3a8d52466380f2fe040c9e
4
- data.tar.gz: 0112d06e16cd88171023f3dd32988348e8dd642334db67c5f33cb1c4194363a4
3
+ metadata.gz: 5493d4177a606096eb7c630c0dd4fbab70a5b68ec013e3033b58217ec7de5c02
4
+ data.tar.gz: f150d164554a4d4a710214b2924fb191aa28e8aee6f87f813cb5c3418e6c9efc
5
5
  SHA512:
6
- metadata.gz: 670382b8c322e3803495d1a294aebfc21036aa69400fb7e9055a98de1243c8ba1e716dadacfcd0909885cbd18e77914f7114f54cb2caf1b388e8e9e386ec00da
7
- data.tar.gz: 54eb9697c17e7fd50f170dd21e7d15ee994459acd4b7151d98def0f2622586ca40f913dba9f451ceb7676ac5c79cb33bb946b55469e2c342372c83ba4fecc189
6
+ metadata.gz: 137467ccfedd59ff4abaa2b4a8f35735b8b02bcde9a9439901905191036d419b221f810f92d57ae66737d45ed492b7b5583e0043006ec68eb7c193bac8fa60d5
7
+ data.tar.gz: 91993e998b3d5fa607b35d38d20c58c3dfe6963d9a3860df6dc2dd9c8f872f496d7bc501e3ae26301bf843987fd53a532fe2eaacd1785c9b6dc7fe093df3776f
data/CHANGELOG.md CHANGED
@@ -2,6 +2,27 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [1.1.0] - 2026-10-18
6
+
7
+ - Add Rails edge CI and experimental Ruby 4.0+ Ractor request support. The host
8
+ must opt into `Rails.application.ractorize!` and use the upstream Rack/I18n
9
+ dependencies documented in `docs/ractors.md`.
10
+ - Exercise actual multipart uploads and private downloads in worker Ractors
11
+ when Active Storage is installed, alongside the optional-dependency-free host.
12
+ - With Action Text, dashboard/editor and tour-content responses run on the
13
+ main Ractor because Nokogiri is not Ractor safe. Those paths buffer request
14
+ bodies and responses and do not gain parallel execution; other paths stay
15
+ on workers.
16
+ - Prepare immutable widget assets and a frozen configuration snapshot at
17
+ Ractorization. Callbacks still run for each request, with isolated captures;
18
+ callbacks that capture mutable state fail at boot with the setting name.
19
+ Normal Rails boots retain mutable configuration and lazy asset loading.
20
+ - Bridge framework operations that are not yet Ractor safe (mounted URL
21
+ generation, digests/key derivation and attribute factories) to the main
22
+ Ractor. This adds dispatch overhead in Ractor mode; it is compatibility
23
+ support, not a claim of improved throughput. Existing Rails 7.1–8.1 installs
24
+ need no migration or initializer changes.
25
+
5
26
  ## [1.0.0] - 2026-08-11
6
27
 
7
28
  - **The record is a tour, all the way down.** `ProductTours::Post` is now
data/README.md CHANGED
@@ -447,3 +447,9 @@ touch your own CSS — and migrations that follow your app's `primary_key_type`.
447
447
 
448
448
  MIT. If it saved you a subscription, a
449
449
  [⭐](https://github.com/yshmarov/product_tours) is a fair trade.
450
+
451
+ ## Rails edge and Ractors
452
+
453
+ Rails edge is tested alongside Rails 7.1–8.1. Experimental Ruby 4.0+ Ractor hosting
454
+ requires additional upstream dependencies and freezes configuration at startup.
455
+ See [the Ractor hosting guide](docs/ractors.md) for callbacks, requirements and checks.
data/Rakefile CHANGED
@@ -5,7 +5,7 @@ require 'rake/testtask'
5
5
 
6
6
  Rake::TestTask.new(:test) do |task|
7
7
  task.libs << 'test'
8
- task.test_files = FileList['test/**/*_test.rb'].exclude('test/system/**/*')
8
+ task.test_files = FileList['test/**/*_test.rb'].exclude('test/system/**/*', 'test/ractor/**/*')
9
9
  end
10
10
 
11
11
  namespace :test do
@@ -17,3 +17,11 @@ namespace :test do
17
17
  end
18
18
 
19
19
  task default: :test
20
+
21
+ namespace :test do
22
+ desc 'Run isolated configuration and Rails edge Ractor request tests (Ruby 4.0+)'
23
+ task :ractor do
24
+ ruby '-Itest', 'test/ractor/configuration_test.rb'
25
+ ruby '-Itest', 'test/ractor/requests_test.rb'
26
+ end
27
+ end
@@ -10,6 +10,13 @@ module ProductTours
10
10
 
11
11
  enum :status, STATUSES.index_by(&:itself), default: :draft
12
12
 
13
+ # Explicit methods work in non-main Ractors; Rails' enum-generated
14
+ # predicates and bang methods are currently unisolated closures.
15
+ def draft? = status_for_database == 'draft'
16
+ def draft! = update!(status: 'draft')
17
+ def published? = status_for_database == 'published'
18
+ def published! = update!(status: 'published')
19
+
13
20
  has_rich_text :description if respond_to?(:has_rich_text)
14
21
 
15
22
  has_one_attached :video, service: ProductTours.config.storage_service if defined?(::ActiveStorage)
data/docs/ractors.md ADDED
@@ -0,0 +1,138 @@
1
+ # Rails edge and experimental Ractor hosting
2
+
3
+ The regular CI matrix covers Rails 7.1, 7.2, 8.0 and 8.1. A required Rails
4
+ edge job runs the same suite against `rails/rails` main. A separate job boots
5
+ an eager-loaded host, calls `Rails.application.ractorize!`, and sends real
6
+ Rack requests through the mounted engine in non-main Ractors, including
7
+ simultaneous requests. The Ractor checks run in separate processes because
8
+ Ractorization freezes Rails; they never freeze the ordinary test application.
9
+
10
+ ## Host prerequisites
11
+
12
+ Ractor hosting currently requires Ruby 4.0+, Rails main, Rack main's
13
+ `rack/ractorize`, and the unreleased [I18n Ractor support](https://github.com/ruby-i18n/i18n/pull/741).
14
+ `gemfiles/rails_ractor.gemfile` records the exact I18n revision tested. These
15
+ are development/test dependencies, not new runtime dependencies for released
16
+ Rails. The gem's supported dependency range remains Rails >= 7.1 and < 9.
17
+
18
+ For a host experimenting with Ractor hosting, the tested upstream overrides are:
19
+
20
+ ```ruby
21
+ gem "rails", github: "rails/rails", branch: "main"
22
+ gem "rack", github: "rack/rack", branch: "main"
23
+ gem "i18n", github: "Shopify/i18n", ref: "58aa1dcd3e04c34831e1c2ec06c83391559c7c91"
24
+ ```
25
+
26
+ Follow Rails' own Ractor hosting requirements: disable reloading, eager-load
27
+ application code, use a shareable logger/cache and a Ractor-compatible job
28
+ adapter. Before application classes load, enable Rails' callback isolation:
29
+
30
+ ```ruby
31
+ require "active_support/ractors"
32
+ ActiveSupport::Ractors.unshareable_proc_action = :raise
33
+ ```
34
+
35
+ Our isolated fixture uses `ActiveSupport::Ractors::Logger`, a null
36
+ cache, and (where jobs are loaded) `:inline`. Its separate shared counter proves
37
+ that public submission limits remain effective across worker Ractors. The
38
+ ordinary dummy application's `:test` job adapter is unchanged.
39
+
40
+ The engine prepares itself automatically on `before_ractorize`; ordinary Rails
41
+ boots do not freeze its configuration or load the experimental I18n extension.
42
+ An unavailable I18n Ractor extension produces a startup error explaining the
43
+ prerequisite. Rails edge and Ractor hosting remain experimental: this is not a
44
+ promise that every third-party host middleware, database driver, storage
45
+ service, mail adapter or Action Cable adapter is Ractor safe.
46
+
47
+ ## Configuration and callbacks
48
+
49
+ Finish configuring ProductTours before calling `ractorize!`. The engine publishes
50
+ a frozen copy of the configuration and warms/freezes widget JS, CSS and content
51
+ fingerprints. Changes after Ractorization require a process restart. Arrays and
52
+ hashes supplied by the host are copied before freezing.
53
+
54
+ Callbacks are isolated with Ruby's `Ractor.shareable_lambda` or
55
+ `Ractor.shareable_proc`, preserving lambda/proc arity. They continue to receive
56
+ the same request, user, record or event arguments and run on each request.
57
+ Isolated callbacks use arguments and constants; their implicit initializer
58
+ `self` becomes nil, following Rails' isolated callback convention.
59
+
60
+ ```ruby
61
+ ProductTours.configure do |config|
62
+ config.enabled = ->(request) { request.get_header("HTTP_X_WIDGET") != "off" }
63
+ end
64
+ ```
65
+
66
+ Immutable captured values must already be shareable:
67
+
68
+ ```ruby
69
+ expected_host = "app.example.com".freeze
70
+ ProductTours.configure do |config|
71
+ config.enabled = ->(request) { request.host == expected_host }
72
+ end
73
+ ```
74
+
75
+ Mutable captures (an array collecting notifications, an unfrozen captured
76
+ string, or reassigned outer locals) cannot be shared. The engine fails at
77
+ startup with `ProductTours.config.<setting>` in the error rather than failing on
78
+ a visitor's request. Use request-local state and external persistence for
79
+ side effects. A pre-isolated callback made with an explicit shareable `self:`
80
+ keeps that receiver. Stateful callable objects must be copyable/shareable;
81
+ custom objects remain subject to Ruby's Ractor rules.
82
+
83
+ ## Scope and cost
84
+
85
+ Some Rails main paths are still being converted to Ractors. The engine warms
86
+ model attributes and controller names, prepares ETag callbacks, and supplies
87
+ narrow bridges for mounted URL generation, cache freshness, digest/key
88
+ operations, GlobalID URI decoding and boot-time attribute decorators. Those bridges preserve the
89
+ original operation on the main Ractor; requests, authorization callbacks,
90
+ rendering and persistence orchestration execute in their worker Ractors.
91
+ Adapter name quoting delegates to its original implementation on the main Ractor,
92
+ which preserves escaping and avoids process-global mutable caches in workers.
93
+ Disk-backed uploads use Ractor-local storage clients and plain temporary files
94
+ with explicit cleanup, because Tempfile's generated delegation methods cannot
95
+ be called from another Ractor. MIME registrations must finish before
96
+ Ractorization, when Marcel's MIME tables become immutable. Eager model schema
97
+ preparation adds startup database work.
98
+ The bridges are internal compatibility code, add coordination overhead, and
99
+ must be reviewed as upstream Rails evolves. No throughput improvement is claimed.
100
+
101
+ ## Run the checks
102
+
103
+ Use the Ruby configured in `mise.toml`, never macOS's system Ruby.
104
+
105
+ ```sh
106
+ BUNDLE_GEMFILE=gemfiles/rails_edge.gemfile bundle install
107
+ BUNDLE_GEMFILE=gemfiles/rails_edge.gemfile bundle exec rake test
108
+ BUNDLE_GEMFILE=gemfiles/rails_ractor.gemfile bundle install
109
+ BUNDLE_GEMFILE=gemfiles/rails_ractor.gemfile bundle exec rake test:ractor
110
+ ```
111
+
112
+ The request fixture creates and removes its own temporary database and host;
113
+ it does not reuse or freeze `test/dummy`. The shared schema lives in
114
+ `test/support/schema.rb` so the ordinary and Ractor suites exercise the same
115
+ record shape. Successful checks establish the tested SQLite host and explicit
116
+ request paths, not all combinations of host integrations.
117
+
118
+ The media job also runs with `RACTOR_MEDIA=1`, exercising private disk-backed
119
+ Active Storage downloads through the engine's authorization gate. Storage
120
+ services are initialized per Ractor, and streamed responses use a thread local
121
+ to that Ractor. Livechat additionally runs with `RACTOR_CABLE=1`, checking the
122
+ signed stream token and broadcasts delegated to the main Action Cable server.
123
+ Other storage services and production Cable adapters need host-specific QA.
124
+
125
+ ## Action Text and Nokogiri
126
+
127
+ When Action Text is loaded, Nokogiri's native parser refuses to run outside the
128
+ main Ractor. Product Tours therefore delegates its HTML dashboard/editor paths
129
+ and `/widget/tour` responses to the main Rails application. It keeps the original
130
+ Action Text sanitizer, rendering, callbacks and transactions intact, including
131
+ rich-text editing. Request bodies and responses for those paths are buffered;
132
+ large editor uploads therefore have an extra memory cost. These paths do not
133
+ run in parallel. Static assets, lifecycle signals and gated media downloads
134
+ continue in worker Ractors. A host without Action Text does not use this fallback.
135
+ The Ractor media CI job covers both video uploads and rich-text rendering.
136
+
137
+ The Product Tours Ractor matrix separately checks a minimal host, an Active
138
+ Storage host without Action Text (`RACTOR_TEXT=0`), and a host with both engines.
@@ -4,8 +4,20 @@ require 'rails'
4
4
 
5
5
  module ProductTours
6
6
  class Engine < ::Rails::Engine
7
+ initializer 'product_tours.ractor_middleware' do |app|
8
+ app.middleware.insert_before 0, RactorSupport::Middleware
9
+ end
10
+
7
11
  isolate_namespace ProductTours
8
12
 
13
+ initializer 'product_tours.ractorize', before: 'action_controller.ractorize' do
14
+ if Rails::Application.method_defined?(:ractorize!)
15
+ Engine.routes.singleton_class.prepend(RactorSupport::MountedRoutes)
16
+ end
17
+
18
+ ActiveSupport.on_load(:before_ractorize) { ProductTours::RactorSupport.prepare! }
19
+ end
20
+
9
21
  initializer 'product_tours.helpers' do
10
22
  ActiveSupport.on_load(:action_view) do
11
23
  include ProductTours::WidgetHelper
@@ -0,0 +1,519 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ProductTours
4
+ # Used only by Rails' opt-in before_ractorize hook. Normal Rails boots keep
5
+ # mutable configuration and lazy assets, including on older Ruby versions.
6
+ module RactorSupport
7
+ # Small bridges for framework paths that Rails main does not prepare yet.
8
+ # They are installed only when the host explicitly calls ractorize!.
9
+ # Rails' built-in JSON renderer is still defined from an unisolated Proc.
10
+ # Keep its JSON/JSONP and escaping behavior in a regular Ruby method.
11
+ module JsonRendering
12
+ def _render_with_renderer_json(json, options)
13
+ json_options = options.except(:callback, :content_type, :status)
14
+ json_options[:escape] ||= false if !self.class.escape_json_responses? && options[:callback].blank?
15
+ json = json.to_json(json_options) unless json.is_a?(String)
16
+ if options[:callback].present?
17
+ self.content_type = :js if media_type.nil? || media_type == Mime[:json]
18
+ "/**/#{options[:callback]}(#{json})"
19
+ else
20
+ self.content_type = :json if media_type.nil?
21
+ json
22
+ end
23
+ end
24
+ end
25
+
26
+ module DependencyInterlock
27
+ def interlock
28
+ return super if Ractor.main?
29
+
30
+ Ractor[:engine_dependency_interlock] ||= ActiveSupport::Dependencies::Interlock.new
31
+ end
32
+ end
33
+
34
+ module LiveThreads
35
+ private
36
+
37
+ def new_controller_thread
38
+ return super if Ractor.main?
39
+
40
+ Thread.new do
41
+ Thread.current.abort_on_exception = true
42
+ yield
43
+ end
44
+ end
45
+ end
46
+
47
+ module UniquenessCoverage
48
+ private
49
+
50
+ def covered_by_unique_index?(klass, record, attribute, scope)
51
+ return super unless frozen?
52
+
53
+ copies = Ractor[:engine_uniqueness_validators] ||= {}
54
+ copy = copies[[self, klass]] ||= dup
55
+ copy.send(:covered_by_unique_index?, klass, record, attribute, scope)
56
+ end
57
+ end
58
+
59
+ # GlobalID calls CGI.unescape without an encoding, whose default reads a
60
+ # process-wide class variable. Preserve CGI's configured default on main.
61
+ module UriUnescaping
62
+ def unescape(string, *arguments)
63
+ return super if Ractor.main?
64
+
65
+ shared_arguments = Ractor.make_shareable([string, *arguments], copy: true)
66
+ ActiveSupport::Ractors.on_main(self) { unescape(*shared_arguments) }
67
+ end
68
+ end
69
+
70
+ module CacheFreshness
71
+ def strict_freshness
72
+ return super if Ractor.main?
73
+
74
+ ActiveSupport::Ractors.on_main(self) { strict_freshness }
75
+ end
76
+ end
77
+
78
+ # Attribute decorators (including Rails enums and serialized attributes)
79
+ # are boot-time factories. Keep their original receivers and captures on
80
+ # the main Ractor, rather than changing the semantics of Rails' closures.
81
+ class Decorator
82
+ def initialize(callback)
83
+ @key = RactorSupport.register_decorator(callback)
84
+ freeze
85
+ end
86
+
87
+ def call(*arguments)
88
+ return RactorSupport.call_decorator(@key, arguments) if Ractor.main?
89
+
90
+ shared_arguments = Ractor.make_shareable(arguments, copy: true)
91
+ ActiveSupport::Ractors.on_main(self) { RactorSupport.call_decorator(@key, shared_arguments) }
92
+ end
93
+ end
94
+
95
+ def self.register_decorator(callback)
96
+ @decorators ||= []
97
+ @decorators << callback
98
+ @decorators.length - 1
99
+ end
100
+
101
+ def self.call_decorator(key, arguments)
102
+ @decorators.fetch(key).call(*arguments)
103
+ end
104
+
105
+ def self.prepare_attributes(model)
106
+ prepare_attributes(model.superclass) if model.superclass.respond_to?(:pending_attribute_modifications, true)
107
+ model.send(:pending_attribute_modifications)&.each do |modification|
108
+ next unless modification.respond_to?(:decorator) && modification.decorator.is_a?(Proc)
109
+
110
+ modification.decorator = Decorator.new(modification.decorator)
111
+ end
112
+ end
113
+
114
+ module AdapterQuoting
115
+ def quote_column_name(name)
116
+ return super if Ractor.main?
117
+
118
+ shared_name = Ractor.make_shareable(name, copy: true)
119
+ ActiveSupport::Ractors.on_main(self) { quote_column_name(shared_name) }
120
+ end
121
+
122
+ def quote_table_name(name)
123
+ return super if Ractor.main?
124
+
125
+ shared_name = Ractor.make_shareable(name, copy: true)
126
+ ActiveSupport::Ractors.on_main(self) { quote_table_name(shared_name) }
127
+ end
128
+ end
129
+
130
+ # Route arguments can contain records with native Set caches. Only their
131
+ # to_param value is needed to generate a mount prefix; keep the caller's
132
+ # original record objects in the options passed to the actual route.
133
+ def self.routing_snapshot(value)
134
+ return value if Ractor.shareable?(value)
135
+
136
+ copy = case value
137
+ when ActiveRecord::Base then value.to_param
138
+ when Hash then value.to_h { |key, item| [routing_snapshot(key), routing_snapshot(item)] }
139
+ when Array then value.map { |item| routing_snapshot(item) }
140
+ when Set then Set.new(value.map { |item| routing_snapshot(item) })
141
+ else return Ractor.make_shareable(value, copy: true)
142
+ end
143
+ Ractor.make_shareable(copy)
144
+ end
145
+
146
+ module MountedRoutes
147
+ def find_script_name(options)
148
+ return super unless defined?(Ractor.shareable_proc) && !Ractor.main?
149
+
150
+ shared_options = RactorSupport.routing_snapshot(options)
151
+ prefix, updated = ActiveSupport::Ractors.on_main(self) do
152
+ local_options = shared_options.dup
153
+ [find_script_name(local_options), local_options]
154
+ end
155
+ (shared_options.keys - updated.keys).each { |key| options.delete(key) }
156
+ updated.each do |key, value|
157
+ options[key] = value unless shared_options.key?(key) && shared_options[key] == value
158
+ end
159
+ prefix
160
+ end
161
+ end
162
+
163
+ module Digesting
164
+ def hexdigest(value)
165
+ return super if Ractor.main?
166
+
167
+ shared_value = Ractor.make_shareable(value, copy: true)
168
+ ActiveSupport::Ractors.on_main(self) { hexdigest(shared_value) }
169
+ end
170
+ end
171
+
172
+ module KeyGeneration
173
+ def generate_key(salt, key_size = 64)
174
+ return super if Ractor.main?
175
+
176
+ shared_salt = Ractor.make_shareable(salt, copy: true)
177
+ ActiveSupport::Ractors.on_main(self) { generate_key(shared_salt, key_size) }
178
+ end
179
+ end
180
+
181
+ module UploadCleanup
182
+ def open
183
+ reopen(path, 'rb+')
184
+ self
185
+ end
186
+
187
+ # Tempfile#close takes a positional unlink flag.
188
+ def close(unlink_now = false) # rubocop:disable Style/OptionalBooleanParameter
189
+ super() unless closed?
190
+ unlink if unlink_now
191
+ end
192
+
193
+ def unlink
194
+ File.unlink(path)
195
+ rescue Errno::ENOENT
196
+ nil
197
+ end
198
+
199
+ def close!
200
+ close(true)
201
+ end
202
+ end
203
+
204
+ def self.upload_tempfile(filename, _content_type)
205
+ extension = File.extname(filename.gsub("\0", '%00'))[0, 129]
206
+ Tempfile.create(['RackMultipart', extension]).extend(UploadCleanup)
207
+ end
208
+
209
+ # Nokogiri's native parser is not Ractor safe. Keep Action Text page
210
+ # processing on the main Ractor, preserving Rails' renderer, sanitizer,
211
+ # callbacks and transaction behavior rather than duplicating their logic.
212
+ def self.main_page?(env)
213
+ return false unless @action_text && !Ractor.main?
214
+
215
+ mount = ProductTours.config.mount_path.chomp('/')
216
+ request_path = env['PATH_INFO'].to_s
217
+ return false unless request_path == mount || request_path.start_with?("#{mount}/")
218
+
219
+ path = request_path.delete_prefix(mount)
220
+ path.empty? || path == '/' || path == '/tours' || path.start_with?('/tours/') || path == '/widget/tour'
221
+ end
222
+
223
+ def self.main_page(env)
224
+ headers = env.select do |_key, value|
225
+ Ractor.shareable?(value) || value.is_a?(String) || value.is_a?(Numeric)
226
+ end
227
+ shared_env = Ractor.make_shareable(headers, copy: true)
228
+ shared_input = Ractor.make_shareable(env['rack.input']&.read.to_s)
229
+ ActiveSupport::Ractors.on_main(Rails.application) do
230
+ request_env = shared_env.transform_values { |value| value.is_a?(String) ? value.dup : value }
231
+ request_env['rack.input'] = StringIO.new(shared_input)
232
+ request_env['rack.errors'] = $stderr
233
+ status, response_headers, body = call(request_env)
234
+ content = +''
235
+ begin
236
+ body.each { |part| content << part }
237
+ ensure
238
+ body.close if body.respond_to?(:close)
239
+ end
240
+ [status, response_headers, [content]]
241
+ end
242
+ end
243
+
244
+ class Middleware
245
+ def initialize(app)
246
+ @app = app
247
+ end
248
+
249
+ def call(env)
250
+ return RactorSupport.main_page(env) if RactorSupport.main_page?(env)
251
+
252
+ if (fallbacks = RactorSupport.fallbacks)
253
+ Fiber[:i18n_fallbacks] ||= fallbacks
254
+ env['rack.multipart.tempfile_factory'] ||= RactorSupport.method(:upload_tempfile) unless Ractor.main?
255
+ end
256
+ @app.call(env)
257
+ end
258
+ end
259
+
260
+ def self.fallbacks
261
+ @fallbacks
262
+ end
263
+
264
+ module StorageMetadata
265
+ def analyzed = read_store_attribute(:metadata, :analyzed)
266
+ def identified = read_store_attribute(:metadata, :identified)
267
+ def composed = read_store_attribute(:metadata, :composed)
268
+
269
+ def analyzed=(value)
270
+ write_store_attribute(:metadata, :analyzed, value)
271
+ end
272
+
273
+ def identified=(value)
274
+ write_store_attribute(:metadata, :identified, value)
275
+ end
276
+
277
+ def composed=(value)
278
+ write_store_attribute(:metadata, :composed, value)
279
+ end
280
+ end
281
+
282
+ class StorageServices
283
+ def initialize(configurations)
284
+ @configurations = Ractor.make_shareable(configurations, copy: true)
285
+ freeze
286
+ end
287
+
288
+ def fetch(name, &)
289
+ registry = if Ractor.main?
290
+ RactorSupport.storage_registry
291
+ else
292
+ Ractor[:engine_storage_services] ||= ActiveStorage::Service::Registry.new(@configurations)
293
+ end
294
+ registry.fetch(name, &)
295
+ end
296
+ end
297
+
298
+ def self.storage_registry
299
+ @storage_registry
300
+ end
301
+
302
+ module StorageChecksums
303
+ def checksum_implementation(**options)
304
+ implementation = super
305
+ if Ractor.main? || !implementation.respond_to?(:name) || !implementation.name&.start_with?('OpenSSL::Digest::')
306
+ return implementation
307
+ end
308
+
309
+ DigestFactory.new(implementation.name.delete_prefix('OpenSSL::Digest::'))
310
+ end
311
+ end
312
+
313
+ class DigestFactory
314
+ def initialize(algorithm)
315
+ @algorithm = algorithm.freeze
316
+ freeze
317
+ end
318
+
319
+ def new
320
+ OpenSSL::Digest.new(@algorithm)
321
+ end
322
+
323
+ def file(io)
324
+ new.file(io)
325
+ end
326
+
327
+ def base64digest(value)
328
+ new.base64digest(value)
329
+ end
330
+ end
331
+
332
+ def self.prepare_redirects!
333
+ base = ActionController::Base
334
+ readers = Module.new
335
+ %i[raise_on_open_redirects action_on_open_redirect action_on_path_relative_redirect].each do |key|
336
+ source = base.method(key).source_location&.first
337
+ next unless source&.end_with?('/action_controller/metal/redirecting.rb')
338
+
339
+ value = Ractor.make_shareable(base.public_send(key), copy: true)
340
+ readers.define_method(key, Ractor.shareable_proc { Ractor.main? ? super() : value })
341
+ end
342
+ return if readers.instance_methods(false).empty?
343
+
344
+ base.prepend(readers)
345
+ base.singleton_class.prepend(readers)
346
+ end
347
+
348
+ def self.prepare_previewers!
349
+ ActiveStorage.previewers.each do |previewer|
350
+ readers = Module.new
351
+ previewer.methods.grep(/_exists\?\z/).each do |capability|
352
+ method = previewer.method(capability)
353
+ next unless method.arity.zero? && method.source_location&.first&.include?('/active_storage/previewer/')
354
+
355
+ readers.define_method(capability, Ractor.shareable_proc do
356
+ if Ractor.main?
357
+ super()
358
+ else
359
+ ActiveSupport::Ractors.on_main(self) { public_send(capability) }
360
+ end
361
+ end)
362
+ end
363
+ previewer.singleton_class.prepend(readers) unless readers.instance_methods(false).empty?
364
+ end
365
+ end
366
+
367
+ def self.prepare_storage!
368
+ return unless defined?(ActiveStorage::Blob)
369
+
370
+ prepare_previewers!
371
+ ActiveStorage::Blob.prepend(StorageMetadata) unless ActiveStorage::Blob < StorageMetadata
372
+ %i[EXTENSIONS TYPE_EXTS TYPE_ALIASES TYPE_PARENTS ROOT_XML MAGIC].each do |name|
373
+ Ractor.make_shareable(Marcel.const_get(name)) if Marcel.const_defined?(name)
374
+ end
375
+ ActiveStorage::Service.prepend(StorageChecksums) unless ActiveStorage::Service < StorageChecksums
376
+ unless Ractor.shareable?(ActiveStorage::Blob.services)
377
+ @storage_registry = ActiveStorage::Blob.services
378
+ configurations = @storage_registry.instance_variable_get(:@configurations)
379
+ default_name = ActiveStorage::Blob.service.name
380
+ default_reader = Module.new
381
+ default_reader.define_method(:service, Ractor.shareable_proc do
382
+ Ractor.main? ? super() : services.fetch(default_name)
383
+ end)
384
+ ActiveStorage::Blob.singleton_class.prepend(default_reader)
385
+ ActiveStorage::Blob.services = StorageServices.new(configurations)
386
+ end
387
+ settings = ActiveStorage.class_variables.map { |name| name.to_s.delete_prefix('@@').to_sym }
388
+ settings.select! { |key| ActiveStorage.respond_to?(key) }
389
+ snapshot = settings.to_h { |key| [key, ActiveStorage.public_send(key)] }
390
+ checksum = snapshot[:checksum_implementation]
391
+ if checksum.is_a?(Class) && checksum.name&.start_with?('OpenSSL::Digest::')
392
+ snapshot[:checksum_implementation] = DigestFactory.new(checksum.name.delete_prefix('OpenSSL::Digest::'))
393
+ end
394
+ Ractor.make_shareable(snapshot)
395
+ readers = Module.new
396
+ settings.each do |key|
397
+ readers.define_method(key, Ractor.shareable_proc { Ractor.main? ? super() : snapshot.fetch(key) })
398
+ end
399
+ ActiveStorage.singleton_class.prepend(readers)
400
+ end
401
+
402
+ def self.prepare_framework!
403
+ CGI.singleton_class.prepend(UriUnescaping) unless CGI.singleton_class < UriUnescaping
404
+ Ractor.make_shareable(GlobalID.app) if defined?(GlobalID) && GlobalID.respond_to?(:app)
405
+ I18n.available_locales.each { |locale| I18n.transliterate('ractor', locale: locale) }
406
+ require 'i18n/ractorize'
407
+ require 'tempfile'
408
+ prepare_redirects!
409
+ %i[BASE36_ALPHABET BASE58_ALPHABET].each do |name|
410
+ Ractor.make_shareable(SecureRandom.const_get(name)) if SecureRandom.const_defined?(name)
411
+ end
412
+ uniqueness = ActiveRecord::Validations::UniquenessValidator
413
+ uniqueness.prepend(UniquenessCoverage) unless uniqueness < UniquenessCoverage
414
+ dependencies = ActiveSupport::Dependencies.singleton_class
415
+ dependencies.prepend(DependencyInterlock) unless dependencies < DependencyInterlock
416
+ prepare_storage!
417
+ Ractor.make_shareable(ActiveRecord::Associations::AssociationScope::INSTANCE)
418
+ Ractor.make_shareable(ActionDispatch::Http::Cache::Response::SPECIAL_KEYS)
419
+ freshness = ActionDispatch::Http::Cache::Request.singleton_class
420
+ freshness.prepend(CacheFreshness) unless freshness < CacheFreshness
421
+ fallbacks = I18n.fallbacks
422
+ I18n.available_locales.each { |locale| fallbacks[locale] }
423
+ @fallbacks = Ractor.make_shareable(fallbacks)
424
+ ActiveSupport::Digest.singleton_class.prepend(Digesting) unless ActiveSupport::Digest.singleton_class < Digesting
425
+ ActiveSupport::KeyGenerator.prepend(KeyGeneration) unless ActiveSupport::KeyGenerator < KeyGeneration
426
+ ActiveRecord::Base.connection_handler.connection_pool_list.each do |pool|
427
+ adapter = pool.db_config.adapter_class.singleton_class
428
+ adapter.prepend(AdapterQuoting) unless adapter < AdapterQuoting
429
+ end
430
+ rescue LoadError => e
431
+ raise LoadError,
432
+ "Ractor requests need the upstream I18n Ractor support (see gemfiles/rails_ractor.gemfile): #{e.message}"
433
+ end
434
+
435
+ def self.prepare!
436
+ @action_text = defined?(ActionText) ? true : false
437
+ # A cache store can reference the whole middleware stack and freeze
438
+ # other mounted engines before their own hooks run. Warm every engine's
439
+ # request environment before making any of that graph shareable.
440
+ Rails.application.railties.each do |railtie|
441
+ railtie.env_config if railtie.is_a?(Rails::Engine)
442
+ end
443
+ Engine.instance.env_config
444
+ prepare_framework!
445
+ ActiveRecord::Base.descendants.each do |model|
446
+ next if model.abstract_class? || !model.table_exists?
447
+
448
+ model.reflect_on_all_associations.each { |reflection| reflection.send(:inverse_name) }
449
+ prepare_attributes(model)
450
+ Ractor.make_shareable(model.defined_enums)
451
+ model.defined_enums.each do |attribute, mapping|
452
+ method_name = attribute.pluralize
453
+ source = model.method(method_name).source_location&.first
454
+ next unless source&.end_with?('/active_record/enum.rb')
455
+
456
+ model.define_singleton_method(method_name, Ractor.shareable_proc { mapping })
457
+ end
458
+ model.define_attribute_methods
459
+ if model.respond_to?(:attachment_reflections)
460
+ model.attachment_reflections.each_value(&:named_variants)
461
+ Ractor.make_shareable(model.attachment_reflections)
462
+ end
463
+ end
464
+ ActionController::Base.descendants.each do |controller|
465
+ next unless controller.name&.start_with?('ProductTours::')
466
+
467
+ controller.prepend(LiveThreads) if defined?(ActionController::Live) && controller < ActionController::Live
468
+ renderer_source = controller.instance_method(:_render_with_renderer_json).source_location&.first
469
+ if renderer_source&.end_with?('/action_controller/metal/renderers.rb') && !(controller < JsonRendering)
470
+ controller.prepend(JsonRendering)
471
+ end
472
+ Ractor.make_shareable(controller.controller_name)
473
+ controller.etaggers = controller.etaggers.map { |callback| Ractor.shareable_proc(&callback) }.freeze
474
+ end
475
+ ProductTours.instance_variable_set(:@config, configuration(ProductTours.config))
476
+ %i[javascript dashboard_javascript dashboard_stylesheet fingerprint
477
+ dashboard_fingerprint dashboard_stylesheet_fingerprint].each do |method|
478
+ next unless Widget.respond_to?(method)
479
+
480
+ Widget.public_send(method)
481
+ end
482
+ Widget.instance_variables.each do |variable|
483
+ Widget.instance_variable_set(variable, Ractor.make_shareable(Widget.instance_variable_get(variable)))
484
+ end
485
+ end
486
+
487
+ def self.configuration_value(value)
488
+ case value
489
+ when Proc
490
+ return value if Ractor.shareable?(value)
491
+
492
+ # Like Rails callbacks, isolated callbacks use arguments and constants,
493
+ # not their initializer's implicit self. Keep lambda/proc arity intact.
494
+ value.lambda? ? Ractor.shareable_lambda(&value) : Ractor.shareable_proc(&value)
495
+ when Hash
496
+ value.to_h { |key, item| [configuration_value(key), configuration_value(item)] }.freeze
497
+ when Array
498
+ value.map { |item| configuration_value(item) }.freeze
499
+ else
500
+ Ractor.make_shareable(value, copy: true)
501
+ end
502
+ end
503
+
504
+ def self.configuration(original)
505
+ prepared = original.dup
506
+ original.instance_variables.each do |variable|
507
+ value = original.instance_variable_get(variable)
508
+ begin
509
+ shared = configuration_value(value)
510
+ prepared.instance_variable_set(variable, shared)
511
+ rescue Ractor::Error, TypeError => e
512
+ setting = variable.to_s.delete_prefix('@')
513
+ raise ArgumentError, "ProductTours.config.#{setting} cannot be shared with Ractors: #{e.message}"
514
+ end
515
+ end
516
+ Ractor.make_shareable(prepared)
517
+ end
518
+ end
519
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ProductTours
4
- VERSION = '1.0.0'
4
+ VERSION = '1.1.0'
5
5
  end
@@ -5,9 +5,9 @@ require 'json'
5
5
 
6
6
  module ProductTours
7
7
  module Widget
8
- SOURCE = File.expand_path('widget.js', __dir__)
9
- DASHBOARD_SOURCE = File.expand_path('dashboard.js', __dir__)
10
- DASHBOARD_STYLESHEET_SOURCE = File.expand_path('dashboard.css', __dir__)
8
+ SOURCE = File.expand_path('widget.js', __dir__).freeze
9
+ DASHBOARD_SOURCE = File.expand_path('dashboard.js', __dir__).freeze
10
+ DASHBOARD_STYLESHEET_SOURCE = File.expand_path('dashboard.css', __dir__).freeze
11
11
  RTL_LANGUAGES = %w[ar arc ckb dv fa ha he ks ku ps sd ug ur yi].freeze
12
12
 
13
13
  class << self
data/lib/product_tours.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'product_tours/version'
4
4
  require 'product_tours/configuration'
5
+ require 'product_tours/ractor_support'
5
6
  require 'product_tours/errors'
6
7
  require 'product_tours/video_resolver'
7
8
  require 'product_tours/video_metadata'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: product_tours
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yaroslav Shmarov
@@ -92,6 +92,7 @@ files:
92
92
  - config/locales/product_tours.vi.yml
93
93
  - config/locales/product_tours.zh-CN.yml
94
94
  - config/routes.rb
95
+ - docs/ractors.md
95
96
  - docs/screenshots/01-walkthrough.jpg
96
97
  - docs/screenshots/02-dashboard.jpg
97
98
  - docs/screenshots/03-mobile.jpg
@@ -107,6 +108,7 @@ files:
107
108
  - lib/product_tours/dashboard.js
108
109
  - lib/product_tours/engine.rb
109
110
  - lib/product_tours/errors.rb
111
+ - lib/product_tours/ractor_support.rb
110
112
  - lib/product_tours/seeds.rb
111
113
  - lib/product_tours/version.rb
112
114
  - lib/product_tours/video_metadata.rb
@@ -138,7 +140,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
138
140
  - !ruby/object:Gem::Version
139
141
  version: '0'
140
142
  requirements: []
141
- rubygems_version: 4.0.16
143
+ rubygems_version: 4.0.20
142
144
  specification_version: 4
143
145
  summary: Self-hosted product tours and video tutorials for Rails apps.
144
146
  test_files: []