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 +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +212 -0
- data/Rakefile +8 -0
- data/config/routes.rb +2 -0
- data/lib/async_render/async_helper.rb +29 -0
- data/lib/async_render/controller.rb +20 -0
- data/lib/async_render/current.rb +9 -0
- data/lib/async_render/engine.rb +23 -0
- data/lib/async_render/executor.rb +39 -0
- data/lib/async_render/memoized_helper.rb +16 -0
- data/lib/async_render/middleware.rb +123 -0
- data/lib/async_render/utils.rb +25 -0
- data/lib/async_render/version.rb +3 -0
- data/lib/async_render/warmup.rb +50 -0
- data/lib/async_render.rb +89 -0
- data/lib/generators/async_render/install/install_generator.rb +17 -0
- data/lib/generators/async_render/install/templates/README +28 -0
- data/lib/generators/async_render/install/templates/async_render.rb +38 -0
- metadata +143 -0
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
|
+

|
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
|
+

|
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
|
+

|
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
data/config/routes.rb
ADDED
@@ -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,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
|
data/lib/async_render.rb
ADDED
@@ -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: []
|