rage-rb 1.2.2 → 1.4.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/CHANGELOG.md +16 -0
- data/Gemfile +3 -0
- data/lib/rage/configuration.rb +29 -0
- data/lib/rage/controller/api.rb +18 -1
- data/lib/rage/cookies.rb +241 -0
- data/lib/rage/ext/active_record/connection_pool.rb +277 -0
- data/lib/rage/ext/setup.rb +36 -0
- data/lib/rage/fiber.rb +1 -1
- data/lib/rage/rails.rb +5 -29
- data/lib/rage/session.rb +105 -0
- data/lib/rage/setup.rb +2 -0
- data/lib/rage/templates/Gemfile +1 -1
- data/lib/rage/version.rb +1 -1
- data/lib/rage-rb.rb +30 -0
- metadata +6 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 43393d351828980659e1a5e574887b818727744bf679bca2c1e0e0a164848496
|
4
|
+
data.tar.gz: d732652d4966c9cdcdd1825ec692b6c88286e467c229a2e4acdb7658e2b0a455
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: e23ca7a0309a24e665850c6c7798a3a4d7cb5dd5d8f239346907bc2be0ae3c6446530e5299e2f5a6b08f773281a8e3c570dc19b9ebe1f6cbe1b0712b643ed688
|
7
|
+
data.tar.gz: 9578c4f912c7ce713639acd0c043de9f4b757ff816e60dd5ae9637434699190d8c84af29a0a42fd7c0f0f32ae6724b710429cde6fab20011c23fd79bade36d13
|
data/CHANGELOG.md
CHANGED
@@ -1,5 +1,21 @@
|
|
1
1
|
## [Unreleased]
|
2
2
|
|
3
|
+
## [1.4.0] - 2024-05-01
|
4
|
+
|
5
|
+
### Added
|
6
|
+
|
7
|
+
- Support cookies and sessions (#69).
|
8
|
+
|
9
|
+
### Fixed
|
10
|
+
|
11
|
+
- Improve compatibility with ActiveRecord 7.1 (#80).
|
12
|
+
|
13
|
+
## [1.3.0] - 2024-04-17
|
14
|
+
|
15
|
+
### Added
|
16
|
+
|
17
|
+
- Introduce the `ActiveRecord::ConnectionPool` patch (#78).
|
18
|
+
|
3
19
|
## [1.2.2] - 2024-04-03
|
4
20
|
|
5
21
|
### Fixed
|
data/Gemfile
CHANGED
data/lib/rage/configuration.rb
CHANGED
@@ -15,6 +15,14 @@
|
|
15
15
|
#
|
16
16
|
# > Defines the verbosity of the Rage logger. This option defaults to `:debug` for all environments except production, where it defaults to `:info`. The available log levels are: `:debug`, `:info`, `:warn`, `:error`, `:fatal`, and `:unknown`.
|
17
17
|
#
|
18
|
+
# • _config.secret_key_base_
|
19
|
+
#
|
20
|
+
# > The `secret_key_base` is used as the input secret to the application's key generator, which is used to encrypt cookies. Rage will fall back to the `SECRET_KEY_BASE` environment variable if this is not set.
|
21
|
+
#
|
22
|
+
# • _config.fallback_secret_key_base_
|
23
|
+
#
|
24
|
+
# > Defines one or several old secrets that need to be rotated. Can accept a single key or an array of keys. Rage will fall back to the `FALLBACK_SECRET_KEY_BASE` environment variable if this is not set.
|
25
|
+
#
|
18
26
|
# # Middleware Configuration
|
19
27
|
#
|
20
28
|
# • _config.middleware.use_
|
@@ -85,9 +93,22 @@
|
|
85
93
|
#
|
86
94
|
# > Specifies connection timeout.
|
87
95
|
#
|
96
|
+
# # Transient Settings
|
97
|
+
#
|
98
|
+
# The settings described in this section should be configured using **environment variables** and are either temporary or will become the default in the future.
|
99
|
+
#
|
100
|
+
# • _RAGE_DISABLE_IO_WRITE_
|
101
|
+
#
|
102
|
+
# > Disables the `io_write` hook to fix the ["zero-length iov"](https://bugs.ruby-lang.org/issues/19640) error on Ruby < 3.3.
|
103
|
+
#
|
104
|
+
# • _RAGE_PATCH_AR_POOL_
|
105
|
+
#
|
106
|
+
# > Enables the `ActiveRecord::ConnectionPool` patch to optimize database connection management. Use it to increase throughput under high load.
|
107
|
+
#
|
88
108
|
class Rage::Configuration
|
89
109
|
attr_accessor :logger
|
90
110
|
attr_reader :log_formatter, :log_level
|
111
|
+
attr_writer :secret_key_base, :fallback_secret_key_base
|
91
112
|
|
92
113
|
# used in DSL
|
93
114
|
def config = self
|
@@ -101,6 +122,14 @@ class Rage::Configuration
|
|
101
122
|
@log_level = level.is_a?(Symbol) ? Logger.const_get(level.to_s.upcase) : level
|
102
123
|
end
|
103
124
|
|
125
|
+
def secret_key_base
|
126
|
+
@secret_key_base || ENV["SECRET_KEY_BASE"]
|
127
|
+
end
|
128
|
+
|
129
|
+
def fallback_secret_key_base
|
130
|
+
Array(@fallback_secret_key_base || ENV["FALLBACK_SECRET_KEY_BASE"])
|
131
|
+
end
|
132
|
+
|
104
133
|
def server
|
105
134
|
@server ||= Server.new
|
106
135
|
end
|
data/lib/rage/controller/api.rb
CHANGED
@@ -75,7 +75,7 @@ class RageController::API
|
|
75
75
|
""
|
76
76
|
end
|
77
77
|
|
78
|
-
activerecord_loaded =
|
78
|
+
activerecord_loaded = defined?(::ActiveRecord)
|
79
79
|
|
80
80
|
class_eval <<~RUBY, __FILE__, __LINE__ + 1
|
81
81
|
def __run_#{action}
|
@@ -324,6 +324,18 @@ class RageController::API
|
|
324
324
|
@response ||= Rage::Response.new(@__headers, @__body)
|
325
325
|
end
|
326
326
|
|
327
|
+
# Get the cookie object. See {Rage::Cookies}.
|
328
|
+
# @return [Rage::Cookies]
|
329
|
+
def cookies
|
330
|
+
@cookies ||= Rage::Cookies.new(@__env, self)
|
331
|
+
end
|
332
|
+
|
333
|
+
# Get the session object. See {Rage::Session}.
|
334
|
+
# @return [Rage::Session]
|
335
|
+
def session
|
336
|
+
@session ||= Rage::Session.new(self)
|
337
|
+
end
|
338
|
+
|
327
339
|
# Send a response to the client.
|
328
340
|
#
|
329
341
|
# @param json [String, Object] send a json response to the client; objects like arrays will be serialized automatically
|
@@ -477,4 +489,9 @@ class RageController::API
|
|
477
489
|
# def append_info_to_payload(payload)
|
478
490
|
# payload[:response] = response.body
|
479
491
|
# end
|
492
|
+
|
493
|
+
# Reset the entire session. See {Rage::Session}.
|
494
|
+
def reset_session
|
495
|
+
session.clear
|
496
|
+
end
|
480
497
|
end
|
data/lib/rage/cookies.rb
ADDED
@@ -0,0 +1,241 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "base64"
|
4
|
+
require "time"
|
5
|
+
|
6
|
+
if !defined?(DomainName)
|
7
|
+
fail <<~ERR
|
8
|
+
|
9
|
+
rage-rb depends on domain_name to specify the domain name for cookies. Add the following line to your Gemfile:
|
10
|
+
gem "domain_name"
|
11
|
+
|
12
|
+
ERR
|
13
|
+
end
|
14
|
+
|
15
|
+
class Rage::Cookies
|
16
|
+
# @private
|
17
|
+
def initialize(env, controller)
|
18
|
+
@env = env
|
19
|
+
@headers = controller.headers
|
20
|
+
@request_cookies = {}
|
21
|
+
@parsed = false
|
22
|
+
|
23
|
+
@jar = SimpleJar
|
24
|
+
end
|
25
|
+
|
26
|
+
# Read a cookie.
|
27
|
+
#
|
28
|
+
# @param key [Symbol]
|
29
|
+
# @return [String]
|
30
|
+
def [](key)
|
31
|
+
value = request_cookies[key]
|
32
|
+
@jar.load(value) if value
|
33
|
+
end
|
34
|
+
|
35
|
+
# Get the number of cookies.
|
36
|
+
#
|
37
|
+
# @return [Integer]
|
38
|
+
def size
|
39
|
+
request_cookies.count { |_, v| !v.nil? }
|
40
|
+
end
|
41
|
+
|
42
|
+
# Delete a cookie.
|
43
|
+
#
|
44
|
+
# @param key [Symbol]
|
45
|
+
# @param path [String]
|
46
|
+
# @param domain [String]
|
47
|
+
def delete(key, path: "/", domain: nil)
|
48
|
+
@headers.compare_by_identity
|
49
|
+
|
50
|
+
@request_cookies[key] = nil
|
51
|
+
@headers[set_cookie_key(key)] = Rack::Utils.add_cookie_to_header(nil, key, {
|
52
|
+
value: "", expires: Time.at(0), path: path, domain: domain
|
53
|
+
})
|
54
|
+
end
|
55
|
+
|
56
|
+
# Returns a jar that'll automatically encrypt cookie values before sending them to the client and will decrypt them
|
57
|
+
# for read. If the cookie was tampered with by the user (or a 3rd party), `nil` will be returned.
|
58
|
+
#
|
59
|
+
# This jar requires that you set a suitable secret for the verification on your app's `secret_key_base`.
|
60
|
+
#
|
61
|
+
# @example
|
62
|
+
# cookies.encrypted[:user_id] = current_user.id
|
63
|
+
def encrypted
|
64
|
+
dup.tap { |c| c.jar = EncryptedJar }
|
65
|
+
end
|
66
|
+
|
67
|
+
# Returns a jar that'll automatically set the assigned cookies to have an expiration date 20 years from now.
|
68
|
+
#
|
69
|
+
# @example
|
70
|
+
# cookies.permanent[:user_id] = current_user.id
|
71
|
+
def permanent
|
72
|
+
dup.tap { |c| c.expires = Time.now + 20 * 365 * 24 * 60 * 60 }
|
73
|
+
end
|
74
|
+
|
75
|
+
# Set a cookie.
|
76
|
+
#
|
77
|
+
# @param key [Symbol]
|
78
|
+
# @param value [String, Hash]
|
79
|
+
# @option value [String] :path
|
80
|
+
# @option value [Boolean] :secure
|
81
|
+
# @option value [Boolean] :httponly
|
82
|
+
# @option value [nil, :none, :lax, :strict] :same_site
|
83
|
+
# @option value [Time] :expires
|
84
|
+
# @option value [String, Array<String>, :all] :domain
|
85
|
+
# @option value [String] :value
|
86
|
+
# @example
|
87
|
+
# cookie[:user_id] = current_user.id
|
88
|
+
# @example
|
89
|
+
# cookie[:user_id] = { value: current_user.id, httponly: true, secure: true }
|
90
|
+
def []=(key, value)
|
91
|
+
@headers.compare_by_identity
|
92
|
+
|
93
|
+
unless value.is_a?(Hash)
|
94
|
+
serialized_value = @jar.dump(value)
|
95
|
+
@request_cookies[key] = serialized_value
|
96
|
+
@headers[set_cookie_key(key)] = Rack::Utils.add_cookie_to_header(nil, key, { value: serialized_value, expires: @expires })
|
97
|
+
return
|
98
|
+
end
|
99
|
+
|
100
|
+
if domain = value[:domain]
|
101
|
+
host = @env["HTTP_HOST"]
|
102
|
+
|
103
|
+
_domain = if domain.is_a?(String)
|
104
|
+
domain
|
105
|
+
elsif domain == :all
|
106
|
+
DomainName(host).domain
|
107
|
+
elsif domain.is_a?(Array)
|
108
|
+
host if domain.include?(host)
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
serialized_value = @jar.dump(value[:value])
|
113
|
+
cookie = Rack::Utils.add_cookie_to_header(nil, key, {
|
114
|
+
path: value[:path],
|
115
|
+
secure: value[:secure],
|
116
|
+
expires: value[:expires] || @expires,
|
117
|
+
httponly: value[:httponly],
|
118
|
+
same_site: value[:same_site],
|
119
|
+
value: serialized_value,
|
120
|
+
domain: _domain
|
121
|
+
})
|
122
|
+
|
123
|
+
@request_cookies[key] = serialized_value
|
124
|
+
@headers[set_cookie_key(key)] = cookie
|
125
|
+
end
|
126
|
+
|
127
|
+
def inspect
|
128
|
+
cookies = request_cookies.transform_values do |v|
|
129
|
+
decoded = Base64.urlsafe_decode64(v) rescue nil
|
130
|
+
is_encrypted = decoded&.start_with?(EncryptedJar::PADDING)
|
131
|
+
|
132
|
+
is_encrypted ? "<encrypted>" : v
|
133
|
+
end
|
134
|
+
|
135
|
+
"#<#{self.class.name} @request_cookies=#{cookies.inspect}"
|
136
|
+
end
|
137
|
+
|
138
|
+
private
|
139
|
+
|
140
|
+
def request_cookies
|
141
|
+
return @request_cookies if @parsed
|
142
|
+
|
143
|
+
@parsed = true
|
144
|
+
if cookie_header = @env["HTTP_COOKIE"]
|
145
|
+
cookie_header.split(/; */n).each do |cookie|
|
146
|
+
next if cookie.empty?
|
147
|
+
key, value = cookie.split("=", 2).yield_self { |k, _| [k.to_sym, _] }
|
148
|
+
unless @request_cookies.has_key?(key)
|
149
|
+
@request_cookies[key] = (Rack::Utils.unescape(value, Encoding::UTF_8) rescue value)
|
150
|
+
end
|
151
|
+
end
|
152
|
+
end
|
153
|
+
|
154
|
+
@request_cookies
|
155
|
+
end
|
156
|
+
|
157
|
+
def set_cookie_key(key)
|
158
|
+
@set_cookie_keys ||= Hash.new { |hash, key| hash[key] = "Set-Cookie".dup }
|
159
|
+
@set_cookie_keys[key]
|
160
|
+
end
|
161
|
+
|
162
|
+
protected
|
163
|
+
|
164
|
+
attr_writer :jar, :expires
|
165
|
+
|
166
|
+
####################
|
167
|
+
#
|
168
|
+
# Cookie Jars
|
169
|
+
#
|
170
|
+
####################
|
171
|
+
|
172
|
+
class SimpleJar
|
173
|
+
def self.load(_)
|
174
|
+
_
|
175
|
+
end
|
176
|
+
|
177
|
+
def self.dump(value)
|
178
|
+
value.to_s
|
179
|
+
end
|
180
|
+
end
|
181
|
+
|
182
|
+
class EncryptedJar
|
183
|
+
SALT = "encrypted cookie"
|
184
|
+
PADDING = "00"
|
185
|
+
|
186
|
+
class << self
|
187
|
+
def load(value)
|
188
|
+
box = primary_box
|
189
|
+
|
190
|
+
begin
|
191
|
+
box.decrypt(Base64.urlsafe_decode64(value).byteslice(2..))
|
192
|
+
rescue ArgumentError
|
193
|
+
nil
|
194
|
+
rescue RbNaCl::CryptoError
|
195
|
+
i ||= 0
|
196
|
+
if box = fallback_boxes[i]
|
197
|
+
i += 1
|
198
|
+
retry
|
199
|
+
end
|
200
|
+
end
|
201
|
+
end
|
202
|
+
|
203
|
+
def dump(value)
|
204
|
+
# add two bytes to hold meta information, e.g. in case we
|
205
|
+
# need to change the encryption algorithm in the future
|
206
|
+
Base64.urlsafe_encode64(PADDING + primary_box.encrypt(value.to_s))
|
207
|
+
end
|
208
|
+
|
209
|
+
private
|
210
|
+
|
211
|
+
def primary_box
|
212
|
+
@primary_box ||= begin
|
213
|
+
if !defined?(RbNaCl) || !(Gem::Version.create(RbNaCl::VERSION) >= Gem::Version.create("3.3.0") && Gem::Version.create(RbNaCl::VERSION) < Gem::Version.create("8.0.0"))
|
214
|
+
fail <<~ERR
|
215
|
+
|
216
|
+
rage-rb depends on rbnacl [>= 3.3, < 8.0] to encrypt cookies. Add the following line to your Gemfile:
|
217
|
+
gem "rbnacl"
|
218
|
+
|
219
|
+
ERR
|
220
|
+
end
|
221
|
+
|
222
|
+
unless Rage.config.secret_key_base
|
223
|
+
raise "Rage.config.secret_key_base should be set to use encrypted cookies"
|
224
|
+
end
|
225
|
+
|
226
|
+
RbNaCl::SimpleBox.from_secret_key(
|
227
|
+
RbNaCl::Hash.blake2b(Rage.config.secret_key_base, digest_size: 32, salt: SALT)
|
228
|
+
)
|
229
|
+
end
|
230
|
+
end
|
231
|
+
|
232
|
+
def fallback_boxes
|
233
|
+
@fallback_boxes ||= begin
|
234
|
+
Rage.config.fallback_secret_key_base.map do |key|
|
235
|
+
RbNaCl::SimpleBox.from_secret_key(RbNaCl::Hash.blake2b(key, digest_size: 32, salt: SALT))
|
236
|
+
end
|
237
|
+
end
|
238
|
+
end
|
239
|
+
end # class << self
|
240
|
+
end
|
241
|
+
end
|
@@ -0,0 +1,277 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Rage::Ext::ActiveRecord::ConnectionPool
|
4
|
+
# items can be added but not removed
|
5
|
+
class BlackHoleList
|
6
|
+
def initialize(arr)
|
7
|
+
@arr = arr
|
8
|
+
end
|
9
|
+
|
10
|
+
def <<(el)
|
11
|
+
@arr << el
|
12
|
+
end
|
13
|
+
|
14
|
+
def shift
|
15
|
+
nil
|
16
|
+
end
|
17
|
+
|
18
|
+
def length
|
19
|
+
0
|
20
|
+
end
|
21
|
+
|
22
|
+
def to_a
|
23
|
+
@arr
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
def self.extended(instance)
|
28
|
+
instance.class.alias_method :__checkout__, :checkout
|
29
|
+
instance.class.alias_method :__remove__, :remove
|
30
|
+
|
31
|
+
ActiveRecord::ConnectionAdapters::AbstractAdapter.attr_accessor(:__idle_since)
|
32
|
+
end
|
33
|
+
|
34
|
+
def __init_rage_extension
|
35
|
+
# a map of fibers that are currently waiting for a
|
36
|
+
# connection in the format of { Fiber => timestamp }
|
37
|
+
@__blocked = {}
|
38
|
+
|
39
|
+
# a map of fibers that are currently hodling connections
|
40
|
+
# in the format of { Fiber => Connection }
|
41
|
+
@__in_use = {}
|
42
|
+
|
43
|
+
# a list of all DB connections that are currently idle
|
44
|
+
@__connections = build_new_connections
|
45
|
+
|
46
|
+
# how long a fiber can wait for a connection to become available
|
47
|
+
@__checkout_timeout = checkout_timeout
|
48
|
+
|
49
|
+
# how long a connection can be idle for before disconnecting
|
50
|
+
@__idle_timeout = reaper.frequency
|
51
|
+
|
52
|
+
# how often should we check for fibers that wait for a connection for too long
|
53
|
+
@__timeout_worker_frequency = 0.5
|
54
|
+
|
55
|
+
# reject fibers that wait for a connection for more than `@__checkout_timeout`
|
56
|
+
Iodine.run_every((@__timeout_worker_frequency * 1_000).to_i) do
|
57
|
+
if @__blocked.length > 0
|
58
|
+
current_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
59
|
+
@__blocked.each do |fiber, blocked_since|
|
60
|
+
if (current_time - blocked_since) > @__checkout_timeout
|
61
|
+
@__blocked.delete(fiber)
|
62
|
+
fiber.raise(ActiveRecord::ConnectionTimeoutError, "could not obtain a connection from the pool within #{@__checkout_timeout} seconds; all pooled connections were in use")
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
# resume blocked fibers once connections become available
|
69
|
+
Iodine.subscribe("ext:ar-connection-released") do
|
70
|
+
if @__blocked.length > 0 && @__connections.length > 0
|
71
|
+
f, _ = @__blocked.shift
|
72
|
+
f.resume
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
# unsubscribe on shutdown
|
77
|
+
Iodine.on_state(:on_finish) do
|
78
|
+
Iodine.unsubscribe("ext:ar-connection-released")
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
# Returns true if there is an open connection being used for the current fiber.
|
83
|
+
def active_connection?
|
84
|
+
@__in_use[Fiber.current]
|
85
|
+
end
|
86
|
+
|
87
|
+
# Retrieve the connection associated with the current fiber, or obtain one if necessary.
|
88
|
+
def connection
|
89
|
+
@__in_use[Fiber.current] ||= @__connections.shift || begin
|
90
|
+
fiber, blocked_since = Fiber.current, Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
91
|
+
@__blocked[fiber] = blocked_since
|
92
|
+
Fiber.yield
|
93
|
+
|
94
|
+
@__connections.shift
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
# Signal that the fiber is finished with the current connection and it can be returned to the pool.
|
99
|
+
def release_connection(owner = Fiber.current)
|
100
|
+
if conn = @__in_use.delete(owner)
|
101
|
+
conn.__idle_since = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
102
|
+
@__connections << conn
|
103
|
+
Iodine.publish("ext:ar-connection-released", "", Iodine::PubSub::PROCESS) if @__blocked.length > 0
|
104
|
+
end
|
105
|
+
|
106
|
+
conn
|
107
|
+
end
|
108
|
+
|
109
|
+
# Recover lost connections for the pool.
|
110
|
+
def reap
|
111
|
+
@__in_use.each do |fiber, conn|
|
112
|
+
unless fiber.alive?
|
113
|
+
if conn.active?
|
114
|
+
conn.reset!
|
115
|
+
release_connection(fiber)
|
116
|
+
else
|
117
|
+
@__in_use.delete(fiber)
|
118
|
+
conn.disconnect!
|
119
|
+
__remove__(conn)
|
120
|
+
@__connections += build_new_connections(1)
|
121
|
+
Iodine.publish("ext:ar-connection-released", "", Iodine::PubSub::PROCESS) if @__blocked.length > 0
|
122
|
+
end
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
# Disconnect all connections that have been idle for at least
|
128
|
+
# `minimum_idle` seconds. Connections currently checked out, or that were
|
129
|
+
# checked in less than `minimum_idle` seconds ago, are unaffected.
|
130
|
+
def flush(minimum_idle = @__idle_timeout)
|
131
|
+
return if minimum_idle.nil? || @__connections.length == 0
|
132
|
+
|
133
|
+
current_time, i = Process.clock_gettime(Process::CLOCK_MONOTONIC), 0
|
134
|
+
while i < @__connections.length
|
135
|
+
conn = @__connections[i]
|
136
|
+
if conn.__idle_since && current_time - conn.__idle_since >= minimum_idle
|
137
|
+
conn.__idle_since = nil
|
138
|
+
conn.disconnect!
|
139
|
+
end
|
140
|
+
i += 1
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
# Disconnect all currently idle connections. Connections currently checked out are unaffected.
|
145
|
+
def flush!
|
146
|
+
reap
|
147
|
+
flush(-1)
|
148
|
+
end
|
149
|
+
|
150
|
+
# Yields a connection from the connection pool to the block.
|
151
|
+
def with_connection
|
152
|
+
yield connection
|
153
|
+
ensure
|
154
|
+
release_connection
|
155
|
+
end
|
156
|
+
|
157
|
+
# Returns an array containing the connections currently in the pool.
|
158
|
+
def connections
|
159
|
+
@__connections.to_a
|
160
|
+
end
|
161
|
+
|
162
|
+
# Returns true if a connection has already been opened.
|
163
|
+
def connected?
|
164
|
+
true
|
165
|
+
end
|
166
|
+
|
167
|
+
# Return connection pool's usage statistic.
|
168
|
+
def stat
|
169
|
+
{
|
170
|
+
size: size,
|
171
|
+
connections: size,
|
172
|
+
busy: @__in_use.count { |fiber, _| fiber.alive? },
|
173
|
+
dead: @__in_use.count { |fiber, _| !fiber.alive? },
|
174
|
+
idle: @__connections.length,
|
175
|
+
waiting: @__blocked.length,
|
176
|
+
checkout_timeout: @__checkout_timeout
|
177
|
+
}
|
178
|
+
end
|
179
|
+
|
180
|
+
# Disconnects all connections in the pool, and clears the pool.
|
181
|
+
# Raises `ActiveRecord::ExclusiveConnectionTimeoutError` if unable to gain ownership of all
|
182
|
+
# connections in the pool within a timeout interval (default duration is `checkout_timeout * 2` seconds).
|
183
|
+
def disconnect(raise_on_acquisition_timeout = true, disconnect_attempts = 0)
|
184
|
+
# allow request fibers to release connections, but block from acquiring new ones
|
185
|
+
if disconnect_attempts == 0
|
186
|
+
@__connections = BlackHoleList.new(@__connections)
|
187
|
+
end
|
188
|
+
|
189
|
+
# if some connections are in use, we will wait for up to `@__checkout_timeout * 2` seconds
|
190
|
+
if @__in_use.length > 0 && disconnect_attempts <= @__checkout_timeout * 4
|
191
|
+
Iodine.run_after(500) { disconnect(raise_on_acquisition_timeout, disconnect_attempts + 1) }
|
192
|
+
return
|
193
|
+
end
|
194
|
+
|
195
|
+
pool_connections = @__connections.to_a
|
196
|
+
|
197
|
+
# check if there are still some connections in use
|
198
|
+
if @__in_use.length > 0
|
199
|
+
raise(ActiveRecord::ExclusiveConnectionTimeoutError, "could not obtain ownership of all database connections") if raise_on_acquisition_timeout
|
200
|
+
pool_connections += @__in_use.values
|
201
|
+
@__in_use.clear
|
202
|
+
end
|
203
|
+
|
204
|
+
# disconnect all connections
|
205
|
+
pool_connections.each do |conn|
|
206
|
+
conn.disconnect!
|
207
|
+
__remove__(conn)
|
208
|
+
end
|
209
|
+
|
210
|
+
# create a new pool
|
211
|
+
@__connections = build_new_connections
|
212
|
+
|
213
|
+
# notify blocked fibers that there are new connections available
|
214
|
+
[@__blocked.length, @__connections.length].min.times do
|
215
|
+
Iodine.publish("ext:ar-connection-released", "", Iodine::PubSub::PROCESS)
|
216
|
+
end
|
217
|
+
end
|
218
|
+
|
219
|
+
# Disconnects all connections in the pool, and clears the pool.
|
220
|
+
# The pool first tries to gain ownership of all connections. If unable to
|
221
|
+
# do so within a timeout interval (default duration is `checkout_timeout * 2` seconds),
|
222
|
+
# then the pool is forcefully disconnected without any regard for other connection owning fibers.
|
223
|
+
def disconnect!
|
224
|
+
disconnect(false)
|
225
|
+
end
|
226
|
+
|
227
|
+
# Check out a database connection from the pool, indicating that you want
|
228
|
+
# to use it. You should call #checkin when you no longer need this.
|
229
|
+
def checkout(_ = nil)
|
230
|
+
connection
|
231
|
+
end
|
232
|
+
|
233
|
+
# Check in a database connection back into the pool, indicating that you no longer need this connection.
|
234
|
+
def checkin(conn)
|
235
|
+
fiber = @__in_use.key(conn)
|
236
|
+
release_connection(fiber)
|
237
|
+
end
|
238
|
+
|
239
|
+
# Remove a connection from the connection pool. The connection will
|
240
|
+
# remain open and active but will no longer be managed by this pool.
|
241
|
+
def remove(conn)
|
242
|
+
__remove__(conn)
|
243
|
+
@__in_use.delete_if { |_, c| c == conn }
|
244
|
+
@__connections.delete(conn)
|
245
|
+
end
|
246
|
+
|
247
|
+
def clear_reloadable_connections(raise_on_acquisition_timeout = true)
|
248
|
+
disconnect(raise_on_acquisition_timeout)
|
249
|
+
end
|
250
|
+
|
251
|
+
def clear_reloadable_connections!
|
252
|
+
disconnect(false)
|
253
|
+
end
|
254
|
+
|
255
|
+
def num_waiting_in_queue
|
256
|
+
@__blocked.length
|
257
|
+
end
|
258
|
+
|
259
|
+
# Discards all connections in the pool (even if they're currently in use!),
|
260
|
+
# along with the pool itself. Any further interaction with the pool is undefined.
|
261
|
+
def discard!
|
262
|
+
@__discarded = true
|
263
|
+
(@__connections + @__in_use.values).each { |conn| conn.discard! }
|
264
|
+
end
|
265
|
+
|
266
|
+
def discarded?
|
267
|
+
!!@__discarded
|
268
|
+
end
|
269
|
+
|
270
|
+
private
|
271
|
+
|
272
|
+
def build_new_connections(num_connections = size)
|
273
|
+
(1..num_connections).map do
|
274
|
+
__checkout__.tap { |conn| conn.__idle_since = Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
275
|
+
end
|
276
|
+
end
|
277
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
# set ActiveSupport isolation level
|
2
|
+
if defined?(ActiveSupport::IsolatedExecutionState)
|
3
|
+
ActiveSupport::IsolatedExecutionState.isolation_level = :fiber
|
4
|
+
end
|
5
|
+
|
6
|
+
# release ActiveRecord connections on yield
|
7
|
+
if defined?(ActiveRecord) && ActiveRecord.version < Gem::Version.create("7.1.0")
|
8
|
+
class Fiber
|
9
|
+
def self.defer
|
10
|
+
res = Fiber.yield
|
11
|
+
|
12
|
+
if ActiveRecord::Base.connection_pool.active_connection?
|
13
|
+
ActiveRecord::Base.connection_handler.clear_active_connections!
|
14
|
+
end
|
15
|
+
|
16
|
+
res
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
# make `ActiveRecord::ConnectionPool` work correctly with fibers
|
22
|
+
if defined?(ActiveRecord::ConnectionAdapters::ConnectionPool)
|
23
|
+
ActiveRecord::ConnectionAdapters::ConnectionPool
|
24
|
+
module ActiveRecord::ConnectionAdapters
|
25
|
+
class ConnectionPool
|
26
|
+
def connection_cache_key(_)
|
27
|
+
Fiber.current
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
# patch `ActiveRecord::ConnectionPool`
|
34
|
+
if defined?(ActiveRecord) && ENV["RAGE_PATCH_AR_POOL"]
|
35
|
+
Rage.patch_active_record_connection_pool
|
36
|
+
end
|
data/lib/rage/fiber.rb
CHANGED
@@ -91,7 +91,7 @@ class Fiber
|
|
91
91
|
|
92
92
|
# @private
|
93
93
|
# under normal circumstances, the method is a copy of `yield`, but it can be overriden to perform
|
94
|
-
# additional steps on yielding, e.g. releasing AR connections; see "lib/rage/
|
94
|
+
# additional steps on yielding, e.g. releasing AR connections; see "lib/rage/ext/setup.rb"
|
95
95
|
class << self
|
96
96
|
alias_method :defer, :yield
|
97
97
|
end
|
data/lib/rage/rails.rb
CHANGED
@@ -11,34 +11,6 @@ Iodine.patch_rack
|
|
11
11
|
# configure the framework
|
12
12
|
Rage.config.internal.rails_mode = true
|
13
13
|
|
14
|
-
# patch ActiveRecord's connection pool
|
15
|
-
if defined?(ActiveRecord)
|
16
|
-
Rails.configuration.after_initialize do
|
17
|
-
module ActiveRecord::ConnectionAdapters
|
18
|
-
class ConnectionPool
|
19
|
-
def connection_cache_key(_)
|
20
|
-
Fiber.current
|
21
|
-
end
|
22
|
-
end
|
23
|
-
end
|
24
|
-
end
|
25
|
-
end
|
26
|
-
|
27
|
-
# release ActiveRecord connections on yield
|
28
|
-
if defined?(ActiveRecord)
|
29
|
-
class Fiber
|
30
|
-
def self.defer
|
31
|
-
res = Fiber.yield
|
32
|
-
|
33
|
-
if ActiveRecord::Base.connection_pool.active_connection?
|
34
|
-
ActiveRecord::Base.connection_handler.clear_active_connections!
|
35
|
-
end
|
36
|
-
|
37
|
-
res
|
38
|
-
end
|
39
|
-
end
|
40
|
-
end
|
41
|
-
|
42
14
|
# plug into Rails' Zeitwerk instance to reload the code
|
43
15
|
Rails.autoloaders.main.on_setup do
|
44
16
|
if Iodine.running?
|
@@ -71,6 +43,10 @@ end
|
|
71
43
|
Rails.configuration.after_initialize do
|
72
44
|
if Rails.logger && !Rage.logger
|
73
45
|
rails_logdev = Rails.logger.instance_variable_get(:@logdev)
|
74
|
-
Rage.
|
46
|
+
Rage.configure do
|
47
|
+
config.logger = Rage::Logger.new(rails_logdev.dev) if rails_logdev.is_a?(Logger::LogDevice)
|
48
|
+
end
|
75
49
|
end
|
76
50
|
end
|
51
|
+
|
52
|
+
require "rage/ext/setup"
|
data/lib/rage/session.rb
ADDED
@@ -0,0 +1,105 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "json"
|
4
|
+
|
5
|
+
class Rage::Session
|
6
|
+
# @private
|
7
|
+
KEY = Rack::RACK_SESSION.to_sym
|
8
|
+
|
9
|
+
# @private
|
10
|
+
def initialize(controller)
|
11
|
+
@cookies = controller.cookies.encrypted
|
12
|
+
end
|
13
|
+
|
14
|
+
# Writes the value to the session.
|
15
|
+
#
|
16
|
+
# @param key [Symbol]
|
17
|
+
# @param value [String]
|
18
|
+
def []=(key, value)
|
19
|
+
write_session(add: { key => value })
|
20
|
+
end
|
21
|
+
|
22
|
+
# Returns the value of the key stored in the session or `nil` if the given key is not found.
|
23
|
+
#
|
24
|
+
# @param key [Symbol]
|
25
|
+
def [](key)
|
26
|
+
read_session[key]
|
27
|
+
end
|
28
|
+
|
29
|
+
# Returns the value of the given key from the session, or raises `KeyError` if the given key is not found
|
30
|
+
# and no default value is set. Returns the default value if specified.
|
31
|
+
#
|
32
|
+
# @param key [Symbol]
|
33
|
+
def fetch(key, default = nil, &block)
|
34
|
+
if default.nil?
|
35
|
+
read_session.fetch(key, &block)
|
36
|
+
else
|
37
|
+
read_session.fetch(key, default, &block)
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
# Deletes the given key from the session.
|
42
|
+
#
|
43
|
+
# @param key [Symbol]
|
44
|
+
def delete(key)
|
45
|
+
write_session(remove: key)
|
46
|
+
end
|
47
|
+
|
48
|
+
# Clears the session.
|
49
|
+
def clear
|
50
|
+
write_session(clear: true)
|
51
|
+
end
|
52
|
+
|
53
|
+
# Returns the session as Hash.
|
54
|
+
def to_hash
|
55
|
+
read_session
|
56
|
+
end
|
57
|
+
|
58
|
+
alias_method :to_h, :to_hash
|
59
|
+
|
60
|
+
def empty?
|
61
|
+
read_session.empty?
|
62
|
+
end
|
63
|
+
|
64
|
+
# Returns `true` if the given key is present in the session.
|
65
|
+
def has_key?(key)
|
66
|
+
read_session.has_key?(key)
|
67
|
+
end
|
68
|
+
|
69
|
+
alias_method :key?, :has_key?
|
70
|
+
alias_method :include?, :has_key?
|
71
|
+
|
72
|
+
def each(&block)
|
73
|
+
read_session.each(&block)
|
74
|
+
end
|
75
|
+
|
76
|
+
def dig(*keys)
|
77
|
+
read_session.dig(*keys)
|
78
|
+
end
|
79
|
+
|
80
|
+
def inspect
|
81
|
+
"#<#{self.class.name} @session=#{to_h.inspect}"
|
82
|
+
end
|
83
|
+
|
84
|
+
private
|
85
|
+
|
86
|
+
def write_session(add: nil, remove: nil, clear: nil)
|
87
|
+
if add
|
88
|
+
read_session.merge!(add)
|
89
|
+
elsif remove && read_session.has_key?(remove)
|
90
|
+
read_session.reject! { |k, _| k == remove }
|
91
|
+
elsif clear
|
92
|
+
read_session.clear
|
93
|
+
end
|
94
|
+
|
95
|
+
@cookies[KEY] = { httponly: true, same_site: :lax, value: read_session.to_json }
|
96
|
+
end
|
97
|
+
|
98
|
+
def read_session
|
99
|
+
@session ||= begin
|
100
|
+
JSON.parse(@cookies[KEY] || "{}", symbolize_names: true)
|
101
|
+
rescue JSON::ParserError
|
102
|
+
{}
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
data/lib/rage/setup.rb
CHANGED
data/lib/rage/templates/Gemfile
CHANGED
data/lib/rage/version.rb
CHANGED
data/lib/rage-rb.rb
CHANGED
@@ -71,10 +71,40 @@ module Rage
|
|
71
71
|
@code_loader ||= Rage::CodeLoader.new
|
72
72
|
end
|
73
73
|
|
74
|
+
def self.patch_active_record_connection_pool
|
75
|
+
patch = proc do
|
76
|
+
is_connected = ActiveRecord::Base.connection_pool rescue false
|
77
|
+
if is_connected
|
78
|
+
puts "INFO: Patching ActiveRecord::ConnectionPool"
|
79
|
+
Iodine.on_state(:on_start) do
|
80
|
+
ActiveRecord::Base.connection_pool.extend(Rage::Ext::ActiveRecord::ConnectionPool)
|
81
|
+
ActiveRecord::Base.connection_pool.__init_rage_extension
|
82
|
+
end
|
83
|
+
else
|
84
|
+
puts "WARNING: DB connection is not established - can't patch ActiveRecord::ConnectionPool"
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
if Rage.config.internal.rails_mode
|
89
|
+
Rails.configuration.after_initialize(&patch)
|
90
|
+
else
|
91
|
+
patch.call
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
74
95
|
module Router
|
75
96
|
module Strategies
|
76
97
|
end
|
77
98
|
end
|
99
|
+
|
100
|
+
module Ext
|
101
|
+
module ActiveRecord
|
102
|
+
autoload :ConnectionPool, "rage/ext/active_record/connection_pool"
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
autoload :Cookies, "rage/cookies"
|
107
|
+
autoload :Session, "rage/session"
|
78
108
|
end
|
79
109
|
|
80
110
|
module RageController
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: rage-rb
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 1.
|
4
|
+
version: 1.4.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Roman Samoilov
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2024-
|
11
|
+
date: 2024-05-01 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: thor
|
@@ -105,8 +105,11 @@ files:
|
|
105
105
|
- lib/rage/code_loader.rb
|
106
106
|
- lib/rage/configuration.rb
|
107
107
|
- lib/rage/controller/api.rb
|
108
|
+
- lib/rage/cookies.rb
|
108
109
|
- lib/rage/env.rb
|
109
110
|
- lib/rage/errors.rb
|
111
|
+
- lib/rage/ext/active_record/connection_pool.rb
|
112
|
+
- lib/rage/ext/setup.rb
|
110
113
|
- lib/rage/fiber.rb
|
111
114
|
- lib/rage/fiber_scheduler.rb
|
112
115
|
- lib/rage/logger/json_formatter.rb
|
@@ -128,6 +131,7 @@ files:
|
|
128
131
|
- lib/rage/router/strategies/host.rb
|
129
132
|
- lib/rage/router/util.rb
|
130
133
|
- lib/rage/rspec.rb
|
134
|
+
- lib/rage/session.rb
|
131
135
|
- lib/rage/setup.rb
|
132
136
|
- lib/rage/sidekiq_session.rb
|
133
137
|
- lib/rage/templates/Gemfile
|