rage-rb 1.2.2 → 1.4.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 +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
|