rage-rb 1.2.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 67ad39e9dfc3894475e0e30b1a0219444f99526043fc97d1e2537e9b9fe36356
4
- data.tar.gz: 370bb3ccb73db305325562f55a7149e55dadd5b36ac7073005eeca84c2d4f529
3
+ metadata.gz: cef03d55f96279c7e6d451dc7ce0abce04024a7ca30d07e9c48c0d054c530bb6
4
+ data.tar.gz: c399b5b3d060aee5367c2e3064435649bbb8eba7dcb2bf2bd215730a698f69d8
5
5
  SHA512:
6
- metadata.gz: 1de8e46628ae1450973dfab381ea2f188d75b8ee730add438a5fe3434cc2f4c1e79bf56273742790bd5abcde80613d024f58f1d74ffd877046fca2d823eeefc1
7
- data.tar.gz: 70b8d04b15e963156913d86933c7f41e4a5508e8bf48f71dbb0636df76643a788190c9657802f5431f419dba7b320808d8a8aeb94d82662eb00c0a28f1e0e5d7
6
+ metadata.gz: cc5578e1e51d557a8f30729b0eff9a41d4a651e62c837af53aff3b3f50caee2b827be47a239aeadd47f2e9ff15ff8263f35a032634c1fdb738cb2b2c71787780
7
+ data.tar.gz: 28a70f5c33752ea33dd1474bc4e481ba7b56fd76541544f838226c9b5ace3758b80d9a93b5947c1ea8cbf9374cbae204e9c0e6c5ff40f2adcbc40be3208f5597
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.3.0] - 2024-04-17
4
+
5
+ ### Added
6
+
7
+ - Introduce the `ActiveRecord::ConnectionPool` patch (#78).
8
+
9
+ ## [1.2.2] - 2024-04-03
10
+
11
+ ### Fixed
12
+
13
+ - Correctly determine Rage env (#77).
14
+
3
15
  ## [1.2.1] - 2024-04-03
4
16
 
5
17
  ### Fixed
data/lib/rage/cli.rb CHANGED
@@ -124,7 +124,7 @@ module Rage
124
124
  end
125
125
 
126
126
  def set_env(options)
127
- ENV["RAGE_ENV"] = options[:environment] || ENV["RAGE_ENV"] || ENV["RAILS_ENV"] || ENV["RACK_ENV"] || "development"
127
+ ENV["RAGE_ENV"] = options[:environment] if options[:environment]
128
128
  end
129
129
  end
130
130
 
@@ -85,6 +85,18 @@
85
85
  #
86
86
  # > Specifies connection timeout.
87
87
  #
88
+ # # Transient Settings
89
+ #
90
+ # The settings described in this section should be configured using **environment variables** and are either temporary or will become the default in the future.
91
+ #
92
+ # • _RAGE_DISABLE_IO_WRITE_
93
+ #
94
+ # > Disables the `io_write` hook to fix the ["zero-length iov"](https://bugs.ruby-lang.org/issues/19640) error on Ruby < 3.3.
95
+ #
96
+ # • _RAGE_PATCH_AR_POOL_
97
+ #
98
+ # > Enables the `ActiveRecord::ConnectionPool` patch to optimize database connection management. Use it to increase throughput under high load.
99
+ #
88
100
  class Rage::Configuration
89
101
  attr_accessor :logger
90
102
  attr_reader :log_formatter, :log_level
@@ -75,7 +75,7 @@ class RageController::API
75
75
  ""
76
76
  end
77
77
 
78
- activerecord_loaded = Rage.config.internal.rails_mode && defined?(::ActiveRecord)
78
+ activerecord_loaded = defined?(::ActiveRecord)
79
79
 
80
80
  class_eval <<~RUBY, __FILE__, __LINE__ + 1
81
81
  def __run_#{action}
@@ -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)
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/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.config.logger = Rage::Logger.new(rails_logdev.dev) if rails_logdev.is_a?(Logger::LogDevice)
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/rspec.rb CHANGED
@@ -4,7 +4,7 @@ require "rack/test"
4
4
  require "json"
5
5
 
6
6
  # set up environment
7
- ENV["RAGE_ENV"] ||= ENV["RAILS_ENV"] || ENV["RACK_ENV"] || "test"
7
+ ENV["RAGE_ENV"] ||= "test"
8
8
 
9
9
  # load the app
10
10
  require "bundler/setup"
data/lib/rage/setup.rb CHANGED
@@ -9,3 +9,5 @@ Dir["#{Rage.root}/config/initializers/**/*.rb"].each { |initializer| load(initia
9
9
  Rage.code_loader.setup
10
10
 
11
11
  require_relative "#{Rage.root}/config/routes"
12
+
13
+ require "rage/ext/setup"
@@ -1,6 +1,6 @@
1
1
  source "https://rubygems.org"
2
2
 
3
- gem "rage-rb", "<%= Rage::VERSION %>"
3
+ gem "rage-rb", "~> <%= Rage::VERSION[0..2] %>"
4
4
 
5
5
  # Build JSON APIs with ease
6
6
  # gem "alba"
data/lib/rage/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rage
4
- VERSION = "1.2.1"
4
+ VERSION = "1.3.0"
5
5
  end
data/lib/rage-rb.rb CHANGED
@@ -28,7 +28,7 @@ module Rage
28
28
  end
29
29
 
30
30
  def self.env
31
- @__env ||= Rage::Env.new(ENV["RAGE_ENV"])
31
+ @__env ||= Rage::Env.new(ENV["RAGE_ENV"] || ENV["RAILS_ENV"] || ENV["RACK_ENV"] || "development")
32
32
  end
33
33
 
34
34
  def self.groups
@@ -71,10 +71,37 @@ 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
78
105
  end
79
106
 
80
107
  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.2.1
4
+ version: 1.3.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-04-03 00:00:00.000000000 Z
11
+ date: 2024-04-17 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: thor
@@ -107,6 +107,8 @@ files:
107
107
  - lib/rage/controller/api.rb
108
108
  - lib/rage/env.rb
109
109
  - lib/rage/errors.rb
110
+ - lib/rage/ext/active_record/connection_pool.rb
111
+ - lib/rage/ext/setup.rb
110
112
  - lib/rage/fiber.rb
111
113
  - lib/rage/fiber_scheduler.rb
112
114
  - lib/rage/logger/json_formatter.rb