async_render 0.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: f4233e16eefe728ce0620a058495d7daecaee4a591f433b3637d01c5489896a2
4
+ data.tar.gz: dc2d826a7de38fd8140f696cdc2abd64080ccaa8ec0ad291a3645e68b065b62e
5
+ SHA512:
6
+ metadata.gz: 7bebb2d37c302a42c6af8847850cb6efab86ea40576a31673db7ca9604f19cf091cb9e59326b05fb6fe4c21aee2d1f425ec0919ecfc6e70835ea5d07b56a2450
7
+ data.tar.gz: 9930ca4a9f7db3225ca6fb9407ba62c673a57e70eb2b6dfcf0ed24cf90035ef6836a5f76fa924cd52729ad98e7b32d1b597c76737d7a8934be0d77cae66c0ab3
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright Igor Kasyanchuk
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,212 @@
1
+ # async_render in Ruby on Rails 🚀
2
+
3
+ ![AsyncRender Logo](docs/idea.gif)
4
+
5
+ A Rails gem that enables asynchronous view rendering with warmup capabilities and in-memory memoization to significantly improve your application's performance.
6
+
7
+ On my pet project and my benchmark, I've seen 5-15% improvement in response time. It's really hard to say how much it will improve your application, but it's worth a try.
8
+
9
+ ## ⚡ Ideal use case
10
+
11
+ - you have heavy partials
12
+ - you have partials, with calculations inside, that can be done in background
13
+ - your partials has none or a few dependencies on locals
14
+ - you have HTML request format (see `Limitations` section)
15
+ - you are curious about performance :)
16
+
17
+ ## ✨ Features
18
+
19
+ ![AsyncRender Logo](docs/idea.png)
20
+
21
+ ### 🔄 Async Rendering
22
+ Render multiple view partials asynchronously in background threads, reducing overall page load time by executing independent renders concurrently.
23
+
24
+ ### 🔥 Warmup Rendering
25
+ Pre-render partials in your controller actions before the main view is processed. This allows expensive computations to start early and be ready when needed. This must be used in combination with async rendering.
26
+
27
+ ### 💾 Memoized Rendering
28
+ Cache rendered partials in memory across requests within the same Ruby process, eliminating redundant rendering of static or rarely-changing content. Warning: do not use it for large amount of content, it will eat up your memory.
29
+
30
+ ### ⚡ Smart Thread Pool Management
31
+ Automatically configures thread pool size based on your database connection pool and Rails configuration to prevent resource contention.
32
+
33
+ ## 📦 Installation
34
+
35
+ Add this line to your application's Gemfile:
36
+
37
+ ```ruby
38
+ gem 'async_render'
39
+ ```
40
+
41
+ And then execute:
42
+
43
+ ```bash
44
+ bundle install
45
+ ```
46
+
47
+ ### Generator
48
+
49
+ After installation, run the generator to create an initializer:
50
+
51
+ ```bash
52
+ rails generate async_render:install
53
+ ```
54
+
55
+ This will create `config/initializers/async_render.rb` with all available configuration options.
56
+
57
+ ## Configuration
58
+
59
+ The initializer file allows you to configure AsyncRender:
60
+
61
+ ```ruby
62
+ AsyncRender.configure do |config|
63
+ # Enable/disable async rendering (default: true)
64
+ config.enabled = Rails.env.production?
65
+
66
+ # Timeout for async operations in seconds (default: 10)
67
+ config.timeout = 10
68
+
69
+ # Custom thread pool executor (optional)
70
+ # config.executor = Concurrent::FixedThreadPool.new(10)
71
+
72
+ # Custom state serialization for thread-local data (optional)
73
+ # config.dump_state_proc = -> { { current_user: Current.user&.id } }
74
+ # config.restore_state_proc = ->(state) { Current.user = User.find_by(id: state[:current_user]) }
75
+ end
76
+ ```
77
+
78
+ ## Usage
79
+
80
+ ### Async Rendering
81
+
82
+ Use `async_render` in your views to render partials asynchronously:
83
+
84
+ ```erb
85
+ <!-- app/views/products/show.html.erb -->
86
+ <div class="container">
87
+ <%= async_render 'shared/expensive_sidebar', { user: current_user } %>
88
+
89
+ <div class="main-content">
90
+ <%= @product.name %>
91
+ </div>
92
+
93
+ <%= async_render 'products/recommendations', { product: @product } %>
94
+ </div>
95
+ ```
96
+
97
+ ### Warmup Rendering
98
+
99
+ Pre-render partials in your controller to start expensive operations early:
100
+
101
+ ```ruby
102
+ class ProductsController < ApplicationController
103
+ # Define warmups for specific actions
104
+ before_action :find_product
105
+
106
+ warmups only: [:show] do
107
+ warmup_render 'shared/expensive_sidebar', { user: current_user }
108
+ warmup_render 'products/recommendations', { product: @product }
109
+ end
110
+
111
+ def show
112
+ end
113
+
114
+ private
115
+
116
+ def find_product
117
+ @product = Product.find(params[:id])
118
+ end
119
+ end
120
+ ```
121
+
122
+ Then use the warmed-up partials in your views:
123
+
124
+ ```erb
125
+ <!-- The warmup_render in the controller pre-calculates these -->
126
+ <%= async_render 'shared/expensive_sidebar', user: current_user %>
127
+ <%= async_render 'products/recommendations', product: @product %>
128
+ ```
129
+
130
+ ### Memoized Rendering
131
+
132
+ For content that rarely changes, use memoized rendering to cache results in memory:
133
+
134
+ ```erb
135
+ <!-- This will be rendered once and cached in memory -->
136
+ <%= memoized_render 'shared/footer' %>
137
+
138
+ <!-- With locals - cached based on the locals hash -->
139
+ <%= memoized_render 'users/avatar', { user: current_user } %>
140
+
141
+ <!-- With custom formats -->
142
+ <%= memoized_render 'api/response', { user: @user } %>
143
+ ```
144
+
145
+ ## How It Works
146
+
147
+ ![AsyncRender Logo](docs/how.png)
148
+
149
+ 1. **Async Rendering**: When you use `async_render`, the gem returns a placeholder immediately and schedules the actual rendering in a background thread
150
+ 2. **Middleware Processing**: A Rack middleware intercepts the response and replaces placeholders with the actual rendered content.
151
+ 3. **Thread Safety**: The gem handles thread-local state properly, ensuring CurrentAttributes and other thread-local data work correctly
152
+ 4. **Automatic Pool Sizing**: Thread pool size is automatically determined based on your database pool size and Rails configuration
153
+
154
+ ## Best Practices
155
+
156
+ ### ⚠️ Limitations
157
+
158
+ - doesn't work with view_components yet, just didn't try yet
159
+ - partials you render in background doesn't have access to the request, so you need to pass "current_user" as locals
160
+ - if you use "Current" attributes, you need to pass them as locals, or pass as state to render (see `dump_state_proc` and `restore_state_proc` in the initializer)
161
+ - warmup rendering under the hood uses simply before_action, so you need to prepare data in the controller action, not in the view.
162
+ - for now only HTML request format is supported, but I'm working on it.
163
+
164
+ ### ✅ Do
165
+
166
+ - Use async rendering for expensive, independent view components
167
+ - Warmup partials that you know will be needed
168
+ - Memoize static or rarely-changing content
169
+ - Monitor your database connection pool usage
170
+
171
+ ### ❌ Don't
172
+
173
+ - Use async rendering for trivial partials (overhead may exceed benefits)
174
+ - Share mutable state between parallel renders
175
+ - Rely on request-specific data without proper state management
176
+ - Use excessive async rendering that could exhaust database connections
177
+
178
+ ## Performance Considerations
179
+
180
+ - The gem automatically limits parallelism based on available database connections
181
+ - Default timeout is 10 seconds for async operations
182
+ - Memoized content persists for the lifetime of the Ruby process
183
+ - Consider memory usage when memoizing large amounts of content
184
+
185
+ ## TODO
186
+
187
+ - [ ] add support for view_components
188
+ - [ ] add support for turbo_stream
189
+ - [ ] improve performance? what we can do to make it faster?
190
+ - [ ] support for different cache stores for memoized rendering
191
+ - [ ] support for nested async rendering
192
+ - [ ] added instrumentation for async rendering
193
+ - [ ] better error handling
194
+ - [ ] better documentation
195
+ - [ ] better state management for thread variables
196
+ - [ ] better compatibility with "render" method, if AsyncRender is disabled, 100% fallback to the original render method. This kind of fallback is already implemented, but I think it can be improved.
197
+
198
+ ## Benchmarks
199
+
200
+ I used local and pet project to benchmark the gem.
201
+
202
+ I also used `oha` to benchmark the gem.
203
+
204
+ I do not specify the exact numbers, but I can say that the gem is working as expected. Once gem will be more mature, I will add more benchmarks.
205
+
206
+ ## Contributing
207
+
208
+ Bug reports and pull requests are welcome on GitHub at https://github.com/igorkasyanchuk/async_render.
209
+
210
+ ## License
211
+
212
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ require "bundler/setup"
2
+
3
+ APP_RAKEFILE = File.expand_path("spec/dummy/Rakefile", __dir__)
4
+ load "rails/tasks/engine.rake"
5
+
6
+ load "rails/tasks/statistics.rake"
7
+
8
+ require "bundler/gem_tasks"
data/config/routes.rb ADDED
@@ -0,0 +1,2 @@
1
+ AsyncRender::Engine.routes.draw do
2
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AsyncRender
4
+ module AsyncHelper
5
+ include AsyncRender::Utils
6
+
7
+ POOL = AsyncRender.executor
8
+
9
+ def async_render(partial, locals = {})
10
+ return render(partial, locals) unless AsyncRender.enabled
11
+
12
+ AsyncRender::Current.skip_middleware = false
13
+
14
+ warmup_key = build_memoized_render_key(partial, locals)
15
+ placeholder = AsyncRender::Current.warmup_partials[warmup_key]
16
+ return placeholder if placeholder
17
+
18
+ token = generate_token(partial)
19
+ placeholder = (AsyncRender::PLACEHOLDER_TEMPLATE % token).html_safe
20
+ state = AsyncRender.dump_state_proc&.call
21
+
22
+ AsyncRender::Current.async_futures[token] = Concurrent::Promises.future_on(POOL) do
23
+ AsyncRender::Executor.new(partial:, locals:, state:).call
24
+ end
25
+
26
+ placeholder
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,20 @@
1
+ require "active_support/concern"
2
+
3
+ module AsyncRender
4
+ # Controller concern to set up per-request async rendering context
5
+ module Controller
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ around_action :async_rendering
10
+ end
11
+
12
+ private
13
+
14
+ def async_rendering
15
+ # AsyncRender::Current.async_futures = Concurrent::Hash.new
16
+ # AsyncRender::Current.warmup_partials = Concurrent::Hash.new
17
+ yield
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,9 @@
1
+ require "concurrent-ruby"
2
+
3
+ module AsyncRender
4
+ class Current < ActiveSupport::CurrentAttributes
5
+ attribute :skip_middleware, default: true
6
+ attribute :async_futures, default: Concurrent::Hash.new
7
+ attribute :warmup_partials, default: Concurrent::Hash.new
8
+ end
9
+ end
@@ -0,0 +1,23 @@
1
+ module AsyncRender
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace AsyncRender
4
+
5
+ initializer "async_render.middleware" do |app|
6
+ app.middleware.use AsyncRender::Middleware
7
+ end
8
+
9
+ initializer "async_render.helper" do
10
+ ActiveSupport.on_load :action_view do
11
+ include AsyncRender::AsyncHelper
12
+ include AsyncRender::MemoizedHelper
13
+ end
14
+ end
15
+
16
+ initializer "async_render.warmup" do
17
+ ActiveSupport.on_load :action_controller do
18
+ include AsyncRender::Warmup
19
+ include AsyncRender::Controller
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,39 @@
1
+ module AsyncRender
2
+ class Executor
3
+ def initialize(partial:, locals:, state:, formats: [ :html ])
4
+ @partial = partial
5
+ @locals = locals
6
+ @state = state
7
+ @formats = formats
8
+ end
9
+
10
+ def call
11
+ # Wrap in Rails executor for proper request-local state
12
+ Rails.application.executor.wrap do
13
+ # Ensure we have a database connection for this thread
14
+ begin
15
+ AsyncRender.restore_state_proc&.call(state)
16
+ ApplicationController.renderer.render(partial:, locals:, formats:)
17
+ rescue => e
18
+ Rails.logger.error { "Error rendering #{partial}: #{e.message}" }
19
+ e.backtrace.each { |line| Rails.logger.error { " #{line}" } }
20
+
21
+ if Rails.env.local?
22
+ <<~HTML
23
+ <p style='background-color:red;color:white'>
24
+ Error rendering #{ERB::Util.html_escape(partial)}: #{ERB::Util.html_escape(e.message)}<br>
25
+ #{ERB::Util.html_escape(e.backtrace.join("\n")).gsub("\n", "<br>")}
26
+ </p>
27
+ HTML
28
+ else
29
+ ""
30
+ end
31
+ end
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ attr_reader :partial, :locals, :context, :state, :formats
38
+ end
39
+ end
@@ -0,0 +1,16 @@
1
+ module AsyncRender
2
+ module MemoizedHelper
3
+ include AsyncRender::Utils
4
+
5
+ def memoized_render(partial, locals = nil, formats: [ :html ], **locals_kw)
6
+ return render(partial, locals) unless AsyncRender.enabled
7
+
8
+ effective_locals = normalize_locals(locals, locals_kw)
9
+ key = build_memoized_render_key(partial, effective_locals)
10
+ AsyncRender.memoized_cache.compute_if_absent(key) do
11
+ Rails.logger.info "[AsyncRender] Memoizing: #{partial}" if Rails.env.local?
12
+ ApplicationController.renderer.render(partial: partial, locals: effective_locals, formats: formats)
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ # lib/async_render/middleware.rb
4
+ require "concurrent"
5
+ require "securerandom"
6
+
7
+ module AsyncRender
8
+ class Middleware
9
+ HTML_TYPE = %r{\Atext/html}.freeze
10
+ PATTERN_PREFIX = "<!--ASYNC-PLACEHOLDER:".freeze
11
+ PATTERN_REGEXP = /<!--ASYNC-PLACEHOLDER:([0-9a-z\.\/]+)-->/.freeze
12
+
13
+ def initialize(app)
14
+ @app = app
15
+ end
16
+
17
+ # def benchmark
18
+ # Benchmark.bm(100) do |x|
19
+ # html = ("hello" * 10000) + "hello<!--ASYNC-PLACEHOLDER:123-->World" + ("hello" * 10000)
20
+ # token_to_fragment = { "123" => "Hello" }
21
+
22
+ # x.report("replace") do
23
+ # 1000.times do
24
+ # html1 = html.dup
25
+
26
+ # html1.gsub!(PATTERN_REGEXP) do |match|
27
+ # token = Regexp.last_match(1)
28
+ # token_to_fragment.fetch(token, "")
29
+ # end
30
+
31
+ # # puts 1
32
+ # # puts html1
33
+ # end
34
+ # end
35
+
36
+ # token_to_fragment_2 = { "<!--ASYNC-PLACEHOLDER:123-->" => "Hello" }
37
+
38
+ # x.report("each") do
39
+ # 1000.times do
40
+ # html2 = html.dup
41
+
42
+ # token_to_fragment.each do |token, fragment|
43
+ # html2[token] = fragment
44
+ # end
45
+
46
+ # # puts 2
47
+ # # puts html2
48
+ # end
49
+ # end
50
+ # end
51
+ # end
52
+
53
+ def call(env)
54
+ status, headers, body = @app.call(env)
55
+
56
+ return [ status, headers, body ] if skip?
57
+
58
+ if html?(headers)
59
+ html = +""
60
+ begin
61
+ body.each { |part| html << part }
62
+ ensure
63
+ body.close if body.respond_to?(:close)
64
+ end
65
+
66
+ return [ status, headers, [ html ] ] if !html.include?(PATTERN_PREFIX)
67
+
68
+ # Wait with timeout and collect fragments
69
+ token_to_fragment = {}
70
+ futures_hash = AsyncRender::Current.async_futures
71
+
72
+ if futures_hash.any?
73
+ # Create array of [token, future] pairs to maintain association
74
+ futures_array = futures_hash.to_a
75
+
76
+ # Wait for all futures to complete with timeout
77
+ all_futures = Concurrent::Promises.zip(*futures_array.map(&:last))
78
+ all_futures.wait(AsyncRender.timeout)
79
+
80
+ # Collect results - check each future individually
81
+ futures_array.each do |token, future|
82
+ token_to_fragment[token] = future.fulfilled? ? future.value : ""
83
+ end
84
+ end
85
+
86
+ # token_to_fragment.each do |token, fragment|
87
+ # Rails.logger.info { "[AsyncRender] Replacing: #{token}" } if Rails.env.local?
88
+ # html[token] = fragment
89
+ # end
90
+
91
+ # Single-pass replacement using regex
92
+ html.gsub!(PATTERN_REGEXP) do |match|
93
+ token = Regexp.last_match(1)
94
+ Rails.logger.info { "[AsyncRender] Replacing: #{token}" } if Rails.env.local?
95
+ token_to_fragment.fetch(token, "")
96
+ end
97
+
98
+ AsyncRender::Current.async_futures.clear
99
+ AsyncRender::Current.warmup_partials.clear
100
+
101
+ headers["Content-Length"] = html.bytesize.to_s if headers["Content-Length"]
102
+ [ status, headers, [ html ] ]
103
+ else
104
+ [ status, headers, body ]
105
+ end
106
+ end
107
+
108
+ private
109
+
110
+ def skip?
111
+ AsyncRender::Current.skip_middleware || !AsyncRender.enabled
112
+ end
113
+
114
+ def html?(headers)
115
+ headers["Content-Type"]&.match?(HTML_TYPE)
116
+ end
117
+
118
+ def wait_or_empty(future, timeout = AsyncRender.timeout)
119
+ future.wait(timeout)
120
+ future.fulfilled? ? future.value : ""
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,25 @@
1
+ module AsyncRender
2
+ module Utils
3
+ def generate_token(partial)
4
+ # Fast random token generation using Random.bytes
5
+ partial_id = partial.downcase.gsub(/[^a-z0-9\/]/, "").slice(0, 100)
6
+ random_hex = Random.bytes(5).unpack1("H*")
7
+ "#{partial_id}/#{random_hex}"
8
+ end
9
+
10
+ def build_memoized_render_key(partial, locals)
11
+ return partial if locals.nil? || locals.empty?
12
+ [ partial, locals.to_a.sort_by { |(k, _)| k.to_s } ]
13
+ end
14
+
15
+ def normalize_locals(locals, locals_kw)
16
+ if locals.nil?
17
+ locals_kw
18
+ elsif locals_kw.empty?
19
+ locals
20
+ else
21
+ locals.merge(locals_kw)
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,3 @@
1
+ module AsyncRender
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,50 @@
1
+ require "active_support/concern"
2
+
3
+ module AsyncRender
4
+ module Warmup
5
+ extend ActiveSupport::Concern
6
+
7
+ include AsyncRender::Utils
8
+
9
+ POOL = AsyncRender.executor
10
+
11
+ class_methods do
12
+ # Usage:
13
+ # warmups only: [:show] do
14
+ # warmup_render('users/menu', user: current_user)
15
+ # warmup_render('users/sidebar')
16
+ # end
17
+ def warmups(**filters, &block)
18
+ before_action(**filters) do
19
+ instance_exec(&block) if block
20
+ end
21
+ end
22
+
23
+ # Backwards compatibility with earlier name
24
+ alias_method :warmup_render_before_action, :warmups
25
+ end
26
+
27
+ # Queue an async render for a partial so views can reference it via placeholders.
28
+ # The result is stitched into the HTML by the middleware.
29
+ def warmup_render(partial, locals = {})
30
+ return unless AsyncRender.enabled
31
+
32
+ AsyncRender::Current.skip_middleware = false
33
+
34
+ warmup_key = build_memoized_render_key(partial, locals)
35
+ placeholder = AsyncRender::Current.warmup_partials[warmup_key]
36
+ return placeholder if placeholder
37
+
38
+ token = generate_token(partial)
39
+ placeholder = (AsyncRender::PLACEHOLDER_TEMPLATE % token).html_safe
40
+ state = AsyncRender.dump_state_proc&.call
41
+ AsyncRender::Current.warmup_partials[warmup_key] = placeholder
42
+
43
+ AsyncRender::Current.async_futures[token] = Concurrent::Promises.future_on(POOL) do
44
+ AsyncRender::Executor.new(partial:, locals:, state:).call
45
+ end
46
+
47
+ placeholder
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "async_render/version"
4
+ require "async_render/controller"
5
+ require "async_render/engine"
6
+ require "async_render/middleware"
7
+ require "async_render/current"
8
+ require "async_render/utils"
9
+ require "concurrent"
10
+
11
+ module AsyncRender
12
+ PLACEHOLDER_PREFIX = "<!--ASYNC-PLACEHOLDER:".freeze
13
+ PLACEHOLDER_SUFFIX = "-->".freeze
14
+ PLACEHOLDER_TEMPLATE = "#{PLACEHOLDER_PREFIX}%s#{PLACEHOLDER_SUFFIX}".freeze
15
+
16
+ mattr_accessor :enabled
17
+ mattr_accessor :timeout
18
+ mattr_accessor :executor
19
+ mattr_accessor :dump_state_proc
20
+ mattr_accessor :restore_state_proc
21
+
22
+ @@enabled = true
23
+ @@timeout = 10
24
+ @@executor = nil
25
+ @@dump_state_proc = nil
26
+ @@restore_state_proc = nil
27
+
28
+ def self.configure
29
+ yield self
30
+ end
31
+
32
+ # Lazily build a conservative default executor sized to avoid DB pool contention.
33
+ def self.executor
34
+ @@executor ||= build_default_executor
35
+ end
36
+
37
+ # Global, process-local memoized cache for rendered fragments or values
38
+ # NOTE: This persists across requests in the Ruby process
39
+ def self.memoized_cache
40
+ @memoized_cache ||= Concurrent::Map.new
41
+ end
42
+
43
+ def self.reset_memoized_cache!
44
+ @memoized_cache = Concurrent::Map.new
45
+ end
46
+
47
+ def self.build_default_executor
48
+ # Heuristics: cap by AR pool size and RAILS_MAX_THREADS, with a sane upper bound.
49
+ ar_pool_size = begin
50
+ defined?(ActiveRecord) && ActiveRecord::Base.connection_pool&.size
51
+ rescue StandardError
52
+ nil
53
+ end
54
+
55
+ puma_max_threads = Integer(ENV["RAILS_MAX_THREADS"]) rescue nil
56
+
57
+ # Defaults
58
+ hard_cap = 16
59
+ max_threads = [ ar_pool_size, puma_max_threads, hard_cap ].compact.min || 8
60
+ min_threads = [ 2, max_threads ].min
61
+
62
+ # Concurrent::ThreadPoolExecutor.new(
63
+ # min_threads: min_threads,
64
+ # max_threads: max_threads,
65
+ # idletime: 60,
66
+ # max_queue: 1_000,
67
+ # fallback_policy: :caller_runs
68
+ # )
69
+ #
70
+ Concurrent::FixedThreadPool.new(
71
+ max_threads,
72
+ idletime: 60,
73
+ max_queue: 1_000,
74
+ fallback_policy: :caller_runs
75
+ )
76
+ #
77
+ # Concurrent::CachedThreadPool.new(
78
+ # min_threads: min_threads,
79
+ # max_threads: max_threads,
80
+ # max_queue: 1_000,
81
+ # fallback_policy: :caller_runs
82
+ # )
83
+ end
84
+ end
85
+
86
+ require "async_render/executor"
87
+ require "async_render/warmup"
88
+ require "async_render/async_helper"
89
+ require "async_render/memoized_helper"
@@ -0,0 +1,17 @@
1
+ module AsyncRender
2
+ module Generators
3
+ class InstallGenerator < Rails::Generators::Base
4
+ source_root File.expand_path("templates", __dir__)
5
+
6
+ desc "Creates an AsyncRender initializer file"
7
+
8
+ def copy_initializer
9
+ template "async_render.rb", "config/initializers/async_render.rb"
10
+ end
11
+
12
+ def show_readme
13
+ readme "README" if behavior == :invoke
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,28 @@
1
+ ===============================================================================
2
+
3
+ AsyncRender has been successfully installed! 🎉
4
+
5
+ Next steps:
6
+
7
+ 1. Review the configuration in config/initializers/async_render.rb
8
+ and adjust settings according to your needs.
9
+
10
+ 2. Include the controller concern in your ApplicationController:
11
+
12
+ class ApplicationController < ActionController::Base
13
+ include AsyncRender::Controller
14
+ end
15
+
16
+ 3. Start using parallel rendering in your views:
17
+
18
+ <%= async_render 'expensive_partial', locals: { user: current_user } %>
19
+
20
+ 4. For more advanced usage, check out:
21
+ - Warmup rendering for pre-loading partials
22
+ - Memoized rendering for caching rendered content
23
+ - Custom thread pool configuration
24
+
25
+ See the full documentation at:
26
+ https://github.com/igorkasyanchuk/async_render
27
+
28
+ ===============================================================================
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ AsyncRender.configure do |config|
4
+ # Enable or disable parallel rendering
5
+ # Default: true
6
+ # config.enabled = true
7
+
8
+ # Enable only in production for better performance
9
+ # config.enabled = Rails.env.production?
10
+
11
+ # Timeout for async operations in seconds
12
+ # Default: 10
13
+ # config.timeout = 10
14
+
15
+ # Custom thread pool executor (optional)
16
+ # By default, AsyncRender will create a thread pool sized based on your
17
+ # database connection pool and RAILS_MAX_THREADS environment variable
18
+ # config.executor = Concurrent::FixedThreadPool.new(10)
19
+
20
+ # Custom state serialization for thread-local data (optional)
21
+ # Use this to preserve Current attributes or other thread-local state
22
+ # across async renders
23
+ #
24
+ # Example:
25
+ # config.dump_state_proc = lambda do
26
+ # {
27
+ # current_user_id: Current.user&.id,
28
+ # request_id: Current.request_id,
29
+ # locale: I18n.locale
30
+ # }
31
+ # end
32
+ #
33
+ # config.restore_state_proc = lambda do |state|
34
+ # Current.user = User.find_by(id: state[:current_user_id]) if state[:current_user_id]
35
+ # Current.request_id = state[:request_id]
36
+ # I18n.locale = state[:locale] if state[:locale]
37
+ # end
38
+ end
metadata ADDED
@@ -0,0 +1,143 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: async_render
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Igor Kasyanchuk
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rails
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: concurrent-ruby
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rspec-rails
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: kaminari
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: debug
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '0'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '0'
82
+ - !ruby/object:Gem::Dependency
83
+ name: pg
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '0'
89
+ type: :development
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: '0'
96
+ description: Async render in Rails
97
+ email:
98
+ - igorkasyanchuk@gmail.com
99
+ executables: []
100
+ extensions: []
101
+ extra_rdoc_files: []
102
+ files:
103
+ - MIT-LICENSE
104
+ - README.md
105
+ - Rakefile
106
+ - config/routes.rb
107
+ - lib/async_render.rb
108
+ - lib/async_render/async_helper.rb
109
+ - lib/async_render/controller.rb
110
+ - lib/async_render/current.rb
111
+ - lib/async_render/engine.rb
112
+ - lib/async_render/executor.rb
113
+ - lib/async_render/memoized_helper.rb
114
+ - lib/async_render/middleware.rb
115
+ - lib/async_render/utils.rb
116
+ - lib/async_render/version.rb
117
+ - lib/async_render/warmup.rb
118
+ - lib/generators/async_render/install/install_generator.rb
119
+ - lib/generators/async_render/install/templates/README
120
+ - lib/generators/async_render/install/templates/async_render.rb
121
+ homepage: https://github.com/igorkasyanchuk/async_render
122
+ licenses:
123
+ - MIT
124
+ metadata:
125
+ homepage_uri: https://github.com/igorkasyanchuk/async_render
126
+ rdoc_options: []
127
+ require_paths:
128
+ - lib
129
+ required_ruby_version: !ruby/object:Gem::Requirement
130
+ requirements:
131
+ - - ">="
132
+ - !ruby/object:Gem::Version
133
+ version: '3.0'
134
+ required_rubygems_version: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - ">="
137
+ - !ruby/object:Gem::Version
138
+ version: '0'
139
+ requirements: []
140
+ rubygems_version: 3.6.9
141
+ specification_version: 4
142
+ summary: Async render in Rails
143
+ test_files: []