rage-rb 1.8.0 → 1.10.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4adc4048bfa84558a6b86b15cbcab8e6153858878425bde66db78a975fa46f46
4
- data.tar.gz: 46bef9f42fdd681bdf593f037a73ca8ae2d435acad9eed5ddf4b3740bf61b601
3
+ metadata.gz: 11dd7f4039089ea1f06eb54c332851c1942f7dc646f48f20a648b9504681d61d
4
+ data.tar.gz: ed28d28bcee87dbbb59dcc200540b7b7588d68b3ace8821c55a014005314974a
5
5
  SHA512:
6
- metadata.gz: 754b954d46d065020e0c2184c04f1a13d0c0a64ddf11d1b034062df4cc669460c259032a8bf40a61c876579f1774e50e9b57181d4d3e1301c4610577e11c6734
7
- data.tar.gz: 4207556922f8fd2c82d07ba8664f53847dc1e5afb6bb5d38260ac0ccd428dd011fd8c6bf34f69aa75af97044a838df23c6c36b5baae1ae7cd3d4c08455fc699b
6
+ metadata.gz: fa1d951dcd7a0cb63fbcef9801298266f4bf61f515977dd3c311e7ea563df9dfcc383db282386f997257d35d01695168880aa160437166105aa8e73ada028295
7
+ data.tar.gz: 7c8e571b08e8987ffec6dd31799382ad0c9da35b42fb7aa0988b146f3efac6e8cd35391410f1e2339191a58dbec81ee8bdb4f78728070fc0d4123a4e2e54f4f0
data/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.10.0] - 2024-09-16
4
+
5
+ ### Changed
6
+
7
+ - Enable Rage Connection Pool by default (#103).
8
+ - Allow to preconfigure the app for selected database (#104).
9
+
10
+ ### Added
11
+
12
+ - Add `version` and `middleware` CLI commands (#99).
13
+
14
+ ## [1.9.0] - 2024-08-24
15
+
16
+ ### Added
17
+
18
+ - Static file server (#100).
19
+ - Rails 7.2 compatibility (#101).
20
+
21
+ ### Fixed
22
+
23
+ - Correctly set Rails env (#102).
24
+
3
25
  ## [1.8.0] - 2024-08-06
4
26
 
5
27
  ### Added
data/Gemfile CHANGED
@@ -9,7 +9,7 @@ gem "rake", "~> 13.0"
9
9
 
10
10
  gem "rspec", "~> 3.0"
11
11
  gem "yard"
12
- gem "rubocop", "~> 1.65", require: false
12
+ gem "rubocop", "~> 1.65.0", require: false
13
13
 
14
14
  group :test do
15
15
  gem "http"
data/README.md CHANGED
@@ -59,6 +59,7 @@ Also, see the following integration guides:
59
59
 
60
60
  - [Rails integration](https://github.com/rage-rb/rage/wiki/Rails-integration)
61
61
  - [RSpec integration](https://github.com/rage-rb/rage/wiki/RSpec-integration)
62
+ - [WebSockets guide](https://github.com/rage-rb/rage/wiki/WebSockets-guide)
62
63
 
63
64
  If you are a first-time contributor, make sure to check the [overview doc](https://github.com/rage-rb/rage/blob/master/OVERVIEW.md) that shows how Rage's core components interact with each other.
64
65
 
@@ -154,7 +155,7 @@ end
154
155
  ```ruby
155
156
  class BenchmarksController < ApplicationController
156
157
  def show
157
- render json: World.find(rand(10_000))
158
+ render json: World.find(rand(1..10_000))
158
159
  end
159
160
  end
160
161
  ```
@@ -172,8 +173,8 @@ Status | Changes
172
173
  :white_check_mark: | ~~Automatic code reloading in development with Zeitwerk.~~
173
174
  :white_check_mark: | ~~Support conditional get with `etag` and `last_modified`.~~
174
175
  :white_check_mark: | ~~Expose the `cookies` and `session` objects.~~
176
+ :white_check_mark: | ~~Implement Iodine-based equivalent of Action Cable.~~
175
177
  ⏳ | Expose the `send_data` and `send_file` methods.
176
- ⏳ | Implement Iodine-based equivalent of Action Cable.
177
178
 
178
179
  ## Development
179
180
 
@@ -135,7 +135,7 @@ class Rage::Cable::Channel
135
135
  end
136
136
 
137
137
  is_subscribing = action_name == :subscribed
138
- activerecord_loaded = defined?(::ActiveRecord)
138
+ should_release_connections = Rage.config.internal.should_manually_release_ar_connections?
139
139
 
140
140
  method_name = class_eval <<~RUBY, __FILE__, __LINE__ + 1
141
141
  def __run_#{action_name}(data)
@@ -163,12 +163,10 @@ class Rage::Cable::Channel
163
163
  #{periodic_timers_chunk}
164
164
  #{rescue_handlers_chunk}
165
165
 
166
- #{if activerecord_loaded
166
+ #{if should_release_connections
167
167
  <<~RUBY
168
168
  ensure
169
- if ActiveRecord::Base.connection_pool.active_connection?
170
- ActiveRecord::Base.connection_handler.clear_active_connections!
171
- end
169
+ ActiveRecord::Base.connection_handler.clear_active_connections!(:all)
172
170
  RUBY
173
171
  end}
174
172
  end
data/lib/rage/cli.rb CHANGED
@@ -2,17 +2,57 @@
2
2
 
3
3
  require "thor"
4
4
  require "rack"
5
+ require "rage/version"
5
6
 
6
7
  module Rage
8
+ class CLICodeGenerator < Thor
9
+ include Thor::Actions
10
+
11
+ def self.source_root
12
+ File.expand_path("templates", __dir__)
13
+ end
14
+
15
+ desc "migration NAME", "Generate a new migration."
16
+ def migration(name = nil)
17
+ return help("migration") if name.nil?
18
+
19
+ setup
20
+ Rake::Task["db:new_migration"].invoke(name)
21
+ end
22
+
23
+ desc "model NAME", "Generate a new model."
24
+ def model(name = nil)
25
+ return help("model") if name.nil?
26
+
27
+ setup
28
+ migration("create_#{name.pluralize}")
29
+ @model_name = name.classify
30
+ template("model-template/model.rb", "app/models/#{name.singularize.underscore}.rb")
31
+ end
32
+
33
+ private
34
+
35
+ def setup
36
+ @setup ||= begin
37
+ require "rake"
38
+ load "Rakefile"
39
+ end
40
+ end
41
+ end
42
+
7
43
  class CLI < Thor
8
44
  def self.exit_on_failure?
9
45
  true
10
46
  end
11
47
 
12
48
  desc "new PATH", "Create a new application."
13
- def new(path)
49
+ option :database, aliases: "-d", desc: "Preconfigure for selected database.", enum: %w(mysql trilogy postgresql sqlite3)
50
+ option :help, aliases: "-h", desc: "Show this message."
51
+ def new(path = nil)
52
+ return help("new") if options.help? || path.nil?
53
+
14
54
  require "rage/all"
15
- NewAppGenerator.start([path])
55
+ CLINewAppGenerator.start([path, options[:database]])
16
56
  end
17
57
 
18
58
  desc "s", "Start the app server."
@@ -29,12 +69,15 @@ module Rage
29
69
  app = ::Rack::Builder.parse_file(options[:config] || "config.ru")
30
70
  app = app[0] if app.is_a?(Array)
31
71
 
32
- port = options[:port] || Rage.config.server.port
33
- address = options[:binding] || (Rage.env.production? ? "0.0.0.0" : "localhost")
34
- timeout = Rage.config.server.timeout
35
- max_clients = Rage.config.server.max_clients
72
+ server_options = { service: :http, handler: app }
36
73
 
37
- ::Iodine.listen service: :http, handler: app, port: port, address: address, timeout: timeout, max_clients: max_clients
74
+ server_options[:port] = options[:port] || Rage.config.server.port
75
+ server_options[:address] = options[:binding] || (Rage.env.production? ? "0.0.0.0" : "localhost")
76
+ server_options[:timeout] = Rage.config.server.timeout
77
+ server_options[:max_clients] = Rage.config.server.max_clients
78
+ server_options[:public] = Rage.config.public_file_server.enabled ? Rage.root.join("public").to_s : nil
79
+
80
+ ::Iodine.listen(**server_options)
38
81
  ::Iodine.threads = Rage.config.server.threads_count
39
82
  ::Iodine.workers = Rage.config.server.workers_count
40
83
 
@@ -112,6 +155,57 @@ module Rage
112
155
  IRB.start
113
156
  end
114
157
 
158
+ desc "middleware", "List Rack middleware stack enabled for the application"
159
+ def middleware
160
+ environment
161
+
162
+ Rage.config.middleware.middlewares.each do |middleware|
163
+ say "use #{middleware.first.name}"
164
+ end
165
+ end
166
+
167
+ desc "version", "Return the current version of the framework"
168
+ def version
169
+ puts Rage::VERSION
170
+ end
171
+
172
+ map "generate" => :g
173
+ desc "g TYPE", "Generate new code."
174
+ subcommand "g", CLICodeGenerator
175
+
176
+ map "--tasks" => :tasks
177
+ desc "--tasks", "See the list of available tasks."
178
+ def tasks
179
+ require "io/console"
180
+
181
+ tasks = linked_rake_tasks
182
+ return if tasks.empty?
183
+
184
+ _, max_width = IO.console.winsize
185
+ max_task_name = tasks.max_by { |task| task.name.length }.name.length + 2
186
+ max_comment = max_width - max_task_name - 8
187
+
188
+ tasks.each do |task|
189
+ comment = task.comment.length <= max_comment ? task.comment : "#{task.comment[0...max_comment - 5]}..."
190
+ puts sprintf("rage %-#{max_task_name}s # %s", task.name, comment)
191
+ end
192
+ end
193
+
194
+ def method_missing(method_name, *, &)
195
+ set_env({})
196
+
197
+ if respond_to?(method_name)
198
+ Rake::Task[method_name].invoke
199
+ else
200
+ suggestions = linked_rake_tasks.map(&:name)
201
+ raise UndefinedCommandError.new(method_name.to_s, suggestions, nil)
202
+ end
203
+ end
204
+
205
+ def respond_to_missing?(method_name, include_private = false)
206
+ linked_rake_tasks.any? { |task| task.name == method_name.to_s } || super
207
+ end
208
+
115
209
  private
116
210
 
117
211
  def environment
@@ -123,26 +217,85 @@ module Rage
123
217
  end
124
218
 
125
219
  def set_env(options)
126
- ENV["RAGE_ENV"] = options[:environment] if options[:environment]
220
+ if options[:environment]
221
+ ENV["RAGE_ENV"] = ENV["RAILS_ENV"] = options[:environment]
222
+ elsif ENV["RAGE_ENV"]
223
+ ENV["RAILS_ENV"] = ENV["RAGE_ENV"]
224
+ elsif ENV["RAILS_ENV"]
225
+ ENV["RAGE_ENV"] = ENV["RAILS_ENV"]
226
+ else
227
+ ENV["RAGE_ENV"] = ENV["RAILS_ENV"] = "development"
228
+ end
229
+ end
230
+
231
+ def linked_rake_tasks
232
+ require "rake"
233
+ Rake::TaskManager.record_task_metadata = true
234
+ load "Rakefile"
235
+
236
+ Rake::Task.tasks.select { |task| !task.comment.nil? && task.name.start_with?("db:") }
127
237
  end
128
238
  end
129
239
 
130
- class NewAppGenerator < Thor::Group
240
+ class CLINewAppGenerator < Thor::Group
131
241
  include Thor::Actions
132
242
  argument :path, type: :string
243
+ argument :database, type: :string, required: false
133
244
 
134
245
  def self.source_root
135
246
  File.expand_path("templates", __dir__)
136
247
  end
137
248
 
249
+ def setup
250
+ @use_database = !database.nil?
251
+ end
252
+
138
253
  def create_directory
139
254
  empty_directory(path)
140
255
  end
141
256
 
142
257
  def copy_files
143
- Dir.glob("*", base: self.class.source_root).each do |template|
258
+ inject_templates
259
+ end
260
+
261
+ def install_database
262
+ return unless @use_database
263
+
264
+ @app_name = path.tr("-", "_").downcase
265
+ append_to_file "#{path}/Gemfile", <<~RUBY
266
+
267
+ gem "#{get_db_gem_name}"
268
+ gem "activerecord"
269
+ gem "standalone_migrations", require: false
270
+ RUBY
271
+
272
+ inject_templates("db-templates")
273
+ inject_templates("db-templates/#{database}")
274
+ end
275
+
276
+ private
277
+
278
+ def inject_templates(from = nil)
279
+ root = "#{self.class.source_root}/#{from}"
280
+
281
+ Dir.glob("*", base: root).each do |template|
282
+ next if File.directory?("#{root}/#{template}")
283
+
144
284
  *template_path_parts, template_name = template.split("-")
145
- template(template, "#{path}/#{template_path_parts.join("/")}/#{template_name}")
285
+ template("#{root}/#{template}", [path, *template_path_parts, template_name].join("/"))
286
+ end
287
+ end
288
+
289
+ def get_db_gem_name
290
+ case database
291
+ when "mysql"
292
+ "mysql2"
293
+ when "trilogy"
294
+ "trilogy"
295
+ when "postgresql"
296
+ "pg"
297
+ when "sqlite3"
298
+ "sqlite3"
146
299
  end
147
300
  end
148
301
  end
@@ -102,6 +102,12 @@
102
102
  #
103
103
  # > Specifies connection timeout.
104
104
  #
105
+ # # Static file server
106
+ #
107
+ # • _config.public_file_server.enabled_
108
+ #
109
+ # > Configures whether Rage should serve static files from the public directory. Defaults to `false`.
110
+ #
105
111
  # # Cable Configuration
106
112
  #
107
113
  # • _config.cable.protocol_
@@ -124,9 +130,13 @@
124
130
  #
125
131
  # > Disables the `io_write` hook to fix the ["zero-length iov"](https://bugs.ruby-lang.org/issues/19640) error on Ruby < 3.3.
126
132
  #
127
- # • _RAGE_PATCH_AR_POOL_
133
+ # • _RAGE_DISABLE_AR_POOL_PATCH_
134
+ #
135
+ # > Disables the `ActiveRecord::ConnectionPool` patch and makes Rage use the original ActiveRecord implementation.
128
136
  #
129
- # > Enables the `ActiveRecord::ConnectionPool` patch to optimize database connection management. Use it to increase throughput under high load.
137
+ # _RAGE_DISABLE_AR_WEAK_CONNECTIONS_
138
+ #
139
+ # > Instructs Rage to not reuse Active Record connections between different fibers.
130
140
  #
131
141
  class Rage::Configuration
132
142
  attr_accessor :logger
@@ -165,6 +175,10 @@ class Rage::Configuration
165
175
  @cable ||= Cable.new
166
176
  end
167
177
 
178
+ def public_file_server
179
+ @public_file_server ||= PublicFileServer.new
180
+ end
181
+
168
182
  def internal
169
183
  @internal ||= Internal.new
170
184
  end
@@ -246,10 +260,30 @@ class Rage::Configuration
246
260
  end
247
261
  end
248
262
 
263
+ class PublicFileServer
264
+ attr_accessor :enabled
265
+ end
266
+
249
267
  # @private
250
268
  class Internal
251
269
  attr_accessor :rails_mode
252
270
 
271
+ def patch_ar_pool?
272
+ !ENV["RAGE_DISABLE_AR_POOL_PATCH"] && !Rage.env.test?
273
+ end
274
+
275
+ # whether we should manually release AR connections;
276
+ # AR 7.2+ uses `with_connection` internaly, so we only need to do this for older versions;
277
+ def should_manually_release_ar_connections?
278
+ defined?(ActiveRecord) && ActiveRecord.version < Gem::Version.create("7.2.0")
279
+ end
280
+
281
+ # whether we should manually reconnect closed AR connections;
282
+ # AR 7.1+ does this automatically while executing the query;
283
+ def should_manually_restore_ar_connections?
284
+ defined?(ActiveRecord) && ActiveRecord.version < Gem::Version.create("7.1.0")
285
+ end
286
+
253
287
  def inspect
254
288
  "#<#{self.class.name}>"
255
289
  end
@@ -76,8 +76,6 @@ class RageController::API
76
76
  ""
77
77
  end
78
78
 
79
- activerecord_loaded = defined?(::ActiveRecord)
80
-
81
79
  wrap_parameters_chunk = if __wrap_parameters_key
82
80
  <<~RUBY
83
81
  wrap_key = self.class.__wrap_parameters_key
@@ -95,9 +93,12 @@ class RageController::API
95
93
  RUBY
96
94
  end
97
95
 
96
+ query_cache_enabled = defined?(::ActiveRecord)
97
+ should_release_connections = Rage.config.internal.should_manually_release_ar_connections?
98
+
98
99
  class_eval <<~RUBY, __FILE__, __LINE__ + 1
99
100
  def __run_#{action}
100
- #{if activerecord_loaded
101
+ #{if query_cache_enabled
101
102
  <<~RUBY
102
103
  ActiveRecord::Base.connection_pool.enable_query_cache!
103
104
  RUBY
@@ -119,12 +120,15 @@ class RageController::API
119
120
  #{rescue_handlers_chunk}
120
121
 
121
122
  ensure
122
- #{if activerecord_loaded
123
+ #{if query_cache_enabled
123
124
  <<~RUBY
124
125
  ActiveRecord::Base.connection_pool.disable_query_cache!
125
- if ActiveRecord::Base.connection_pool.active_connection?
126
- ActiveRecord::Base.connection_handler.clear_active_connections!
127
- end
126
+ RUBY
127
+ end}
128
+
129
+ #{if should_release_connections
130
+ <<~RUBY
131
+ ActiveRecord::Base.connection_handler.clear_active_connections!(:all)
128
132
  RUBY
129
133
  end}
130
134
 
@@ -24,11 +24,30 @@ module Rage::Ext::ActiveRecord::ConnectionPool
24
24
  end
25
25
  end
26
26
 
27
+ # reconnect closed connections on checkout;
28
+ # only included with `Rage.config.should_manually_restore_ar_connections?`
29
+ module ConnectionWithVerify
30
+ def connection
31
+ conn = super
32
+
33
+ if conn.__needs_reconnect
34
+ conn.reconnect!
35
+ conn.__needs_reconnect = false
36
+ end
37
+
38
+ conn
39
+ end
40
+ end
41
+ if Rage.config.internal.should_manually_restore_ar_connections?
42
+ prepend ConnectionWithVerify
43
+ end
44
+
27
45
  def self.extended(instance)
28
46
  instance.class.alias_method :__checkout__, :checkout
29
47
  instance.class.alias_method :__remove__, :remove
30
48
 
31
49
  ActiveRecord::ConnectionAdapters::AbstractAdapter.attr_accessor(:__idle_since)
50
+ ActiveRecord::ConnectionAdapters::AbstractAdapter.attr_accessor(:__needs_reconnect)
32
51
  end
33
52
 
34
53
  def __init_rage_extension
@@ -47,7 +66,7 @@ module Rage::Ext::ActiveRecord::ConnectionPool
47
66
  @__checkout_timeout = checkout_timeout
48
67
 
49
68
  # how long a connection can be idle for before disconnecting
50
- @__idle_timeout = reaper.frequency
69
+ @__idle_timeout = respond_to?(:db_config) ? db_config.idle_timeout : @idle_timeout
51
70
 
52
71
  # how often should we check for fibers that wait for a connection for too long
53
72
  @__timeout_worker_frequency = 0.5
@@ -65,8 +84,30 @@ module Rage::Ext::ActiveRecord::ConnectionPool
65
84
  end
66
85
  end
67
86
 
87
+ # monitor connections health
88
+ if Rage.config.internal.should_manually_restore_ar_connections?
89
+ Iodine.run_every(1_000) do
90
+ i = 0
91
+ while i < @__connections.length
92
+ conn = @__connections[i]
93
+
94
+ unless conn.__needs_reconnect
95
+ needs_reconnect = !conn.active? rescue true
96
+ if needs_reconnect
97
+ conn.__needs_reconnect = true
98
+ conn.disconnect!
99
+ end
100
+ end
101
+
102
+ i += 1
103
+ end
104
+ end
105
+ end
106
+
107
+ @release_connection_channel = "ext:ar-connection-released:#{object_id}"
108
+
68
109
  # resume blocked fibers once connections become available
69
- Iodine.subscribe("ext:ar-connection-released") do
110
+ Iodine.subscribe(@release_connection_channel) do
70
111
  if @__blocked.length > 0 && @__connections.length > 0
71
112
  f, _ = @__blocked.shift
72
113
  f.resume
@@ -75,7 +116,7 @@ module Rage::Ext::ActiveRecord::ConnectionPool
75
116
 
76
117
  # unsubscribe on shutdown
77
118
  Iodine.on_state(:on_finish) do
78
- Iodine.unsubscribe("ext:ar-connection-released")
119
+ Iodine.unsubscribe(@release_connection_channel)
79
120
  end
80
121
  end
81
122
 
@@ -100,7 +141,7 @@ module Rage::Ext::ActiveRecord::ConnectionPool
100
141
  if (conn = @__in_use.delete(owner))
101
142
  conn.__idle_since = Process.clock_gettime(Process::CLOCK_MONOTONIC)
102
143
  @__connections << conn
103
- Iodine.publish("ext:ar-connection-released", "", Iodine::PubSub::PROCESS) if @__blocked.length > 0
144
+ Iodine.publish(@release_connection_channel, "", Iodine::PubSub::PROCESS) if @__blocked.length > 0
104
145
  end
105
146
 
106
147
  conn
@@ -108,20 +149,27 @@ module Rage::Ext::ActiveRecord::ConnectionPool
108
149
 
109
150
  # Recover lost connections for the pool.
110
151
  def reap
152
+ crashed_fibers = nil
153
+
111
154
  @__in_use.each do |fiber, conn|
112
155
  unless fiber.alive?
113
156
  if conn.active?
114
157
  conn.reset!
115
- release_connection(fiber)
158
+ (crashed_fibers ||= []) << fiber
116
159
  else
117
160
  @__in_use.delete(fiber)
118
161
  conn.disconnect!
119
162
  __remove__(conn)
163
+ self.automatic_reconnect = true
120
164
  @__connections += build_new_connections(1)
121
- Iodine.publish("ext:ar-connection-released", "", Iodine::PubSub::PROCESS) if @__blocked.length > 0
165
+ Iodine.publish(@release_connection_channel, "", Iodine::PubSub::PROCESS) if @__blocked.length > 0
122
166
  end
123
167
  end
124
168
  end
169
+
170
+ if crashed_fibers
171
+ crashed_fibers.each { |fiber| release_connection(fiber) }
172
+ end
125
173
  end
126
174
 
127
175
  # Disconnect all connections that have been idle for at least
@@ -135,6 +183,7 @@ module Rage::Ext::ActiveRecord::ConnectionPool
135
183
  conn = @__connections[i]
136
184
  if conn.__idle_since && current_time - conn.__idle_since >= minimum_idle
137
185
  conn.__idle_since = nil
186
+ conn.__needs_reconnect = true
138
187
  conn.disconnect!
139
188
  end
140
189
  i += 1
@@ -148,10 +197,14 @@ module Rage::Ext::ActiveRecord::ConnectionPool
148
197
  end
149
198
 
150
199
  # Yields a connection from the connection pool to the block.
151
- def with_connection
152
- yield connection
200
+ def with_connection(_ = nil)
201
+ unless (conn = @__in_use[Fiber.current])
202
+ conn = connection
203
+ fresh_connection = true
204
+ end
205
+ yield conn
153
206
  ensure
154
- release_connection
207
+ release_connection if fresh_connection
155
208
  end
156
209
 
157
210
  # Returns an array containing the connections currently in the pool.
@@ -208,11 +261,12 @@ module Rage::Ext::ActiveRecord::ConnectionPool
208
261
  end
209
262
 
210
263
  # create a new pool
264
+ self.automatic_reconnect = true
211
265
  @__connections = build_new_connections
212
266
 
213
267
  # notify blocked fibers that there are new connections available
214
268
  [@__blocked.length, @__connections.length].min.times do
215
- Iodine.publish("ext:ar-connection-released", "", Iodine::PubSub::PROCESS)
269
+ Iodine.publish(@release_connection_channel, "", Iodine::PubSub::PROCESS)
216
270
  end
217
271
  end
218
272
 
@@ -230,6 +284,10 @@ module Rage::Ext::ActiveRecord::ConnectionPool
230
284
  connection
231
285
  end
232
286
 
287
+ def lease_connection
288
+ connection
289
+ end
290
+
233
291
  # Check in a database connection back into the pool, indicating that you no longer need this connection.
234
292
  def checkin(conn)
235
293
  fiber = @__in_use.key(conn)
@@ -1,19 +1,45 @@
1
+ if defined?(ActiveRecord) && ActiveRecord.version < Gem::Version.create("6")
2
+ fail "Rage is only compatible with Active Record 6+. Detected Active Record version: #{ActiveRecord.version}."
3
+ end
4
+
1
5
  # set ActiveSupport isolation level
2
6
  if defined?(ActiveSupport::IsolatedExecutionState)
3
7
  ActiveSupport::IsolatedExecutionState.isolation_level = :fiber
4
8
  end
5
9
 
10
+ # patch Active Record 6.0 to accept the role argument
11
+ if defined?(ActiveRecord) && ActiveRecord.version < Gem::Version.create("6.1")
12
+ %i(active_connections? connection_pool_list clear_active_connections!).each do |m|
13
+ ActiveRecord::Base.connection_handler.define_singleton_method(m) do |_ = nil|
14
+ super()
15
+ end
16
+ end
17
+ end
18
+
6
19
  # 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
20
+ if defined?(ActiveRecord) && Rage.config.internal.patch_ar_pool?
21
+ if ENV["RAGE_DISABLE_AR_WEAK_CONNECTIONS"]
22
+ unless Rage.config.internal.should_manually_release_ar_connections?
23
+ puts "WARNING: The RAGE_DISABLE_AR_WEAK_CONNECTIONS setting does not have any effect with Active Record 7.2+"
24
+ end
25
+ elsif Rage.config.internal.should_manually_release_ar_connections?
26
+ class Fiber
27
+ def self.defer(fileno)
28
+ f = Fiber.current
29
+ f.__awaited_fileno = fileno
11
30
 
12
- if ActiveRecord::Base.connection_pool.active_connection?
13
- ActiveRecord::Base.connection_handler.clear_active_connections!
14
- end
31
+ res = Fiber.yield
32
+
33
+ if ActiveRecord::Base.connection_handler.active_connections?(:all)
34
+ Iodine.defer do
35
+ if fileno != f.__awaited_fileno || !f.alive?
36
+ ActiveRecord::Base.connection_handler.connection_pool_list(:all).each { |pool| pool.release_connection(f) }
37
+ end
38
+ end
39
+ end
15
40
 
16
- res
41
+ res
42
+ end
17
43
  end
18
44
  end
19
45
  end
@@ -30,7 +56,44 @@ if defined?(ActiveRecord::ConnectionAdapters::ConnectionPool)
30
56
  end
31
57
  end
32
58
 
59
+ # connect to the database in standalone mode
60
+ database_url, database_file = ENV["DATABASE_URL"], Rage.root.join("config/database.yml")
61
+ if defined?(ActiveRecord) && !Rage.config.internal.rails_mode && (database_url || database_file.exist?)
62
+ # transform database URL to an object
63
+ database_url_config = if database_url.nil?
64
+ {}
65
+ elsif ActiveRecord.version >= Gem::Version.create("6.1.0")
66
+ ActiveRecord::Base.configurations
67
+ ActiveRecord::DatabaseConfigurations::ConnectionUrlResolver.new(database_url).to_hash
68
+ else
69
+ ActiveRecord::ConnectionAdapters::ConnectionSpecification::ConnectionUrlResolver.new(database_url).to_hash
70
+ end
71
+ database_url_config.transform_keys!(&:to_s)
72
+
73
+ # load config/database.yml
74
+ if database_file.exist?
75
+ database_file_config = begin
76
+ require "yaml"
77
+ require "erb"
78
+ YAML.safe_load(ERB.new(database_file.read).result, aliases: true)
79
+ end
80
+
81
+ # merge database URL config into the file config (only if we have one database)
82
+ database_file_config.transform_values! do |env_config|
83
+ env_config.all? { |_, v| v.is_a?(Hash) } ? env_config : env_config.merge(database_url_config)
84
+ end
85
+ end
86
+
87
+ ActiveRecord::Base.configurations = database_file_config || { Rage.env.to_s => database_url_config }
88
+ ActiveRecord::Base.establish_connection(Rage.env.to_sym)
89
+
90
+ unless defined?(Rake)
91
+ ActiveRecord::Base.logger = Rage.logger if Rage.logger.debug?
92
+ ActiveRecord::Base.connection_pool.with_connection {} # validate the connection
93
+ end
94
+ end
95
+
33
96
  # patch `ActiveRecord::ConnectionPool`
34
- if defined?(ActiveRecord) && ENV["RAGE_PATCH_AR_POOL"]
97
+ if defined?(ActiveRecord) && !defined?(Rake) && Rage.config.internal.patch_ar_pool?
35
98
  Rage.patch_active_record_connection_pool
36
99
  end
data/lib/rage/fiber.rb CHANGED
@@ -39,6 +39,43 @@
39
39
  # Many developers see fibers as "lightweight threads" that should be used in conjunction with fiber pools, the same way we use thread pools for threads.<br>
40
40
  # Instead, it makes sense to think of fibers as regular Ruby objects. We don't use a pool of arrays when we need to create an array - we create a new object and let Ruby and the GC do their job.<br>
41
41
  # Same applies to fibers. Feel free to create as many fibers as you need on demand.
42
+ #
43
+ # ## Active Record Connections
44
+ #
45
+ # Let's consider the following controller, where we update a record in the database:
46
+ #
47
+ # ```ruby
48
+ # class UsersController < RageController::API
49
+ # def update
50
+ # User.update!(params[:id], email: params[:email])
51
+ # render status: :ok
52
+ # end
53
+ # end
54
+ # ```
55
+ #
56
+ # The `User.update!` call here checks out an Active Record connection, and Rage will automatically check it back in once the action is completed. So far so good!
57
+ #
58
+ # Let's consider another example:
59
+ #
60
+ # ```ruby
61
+ # require "net/http"
62
+ #
63
+ # class UsersController < RageController::API
64
+ # def update
65
+ # User.update!(params[:id], email: params[:email]) # takes 5ms
66
+ # Net::HTTP.post_form(URI("https://mailing.service/update"), { user_id: params[:id] }) # takes 50ms
67
+ # render status: :ok
68
+ # end
69
+ # end
70
+ # ```
71
+ #
72
+ # Here, we've added another step: once the record is updated, we will send a request to update the user's data in the mailing list service.
73
+ #
74
+ # However, in this case, we want to release the Active Record connection before the action is completed. You can see that we need the connection only for the `User.update!` call.
75
+ # The next 50ms the code will spend waiting for the HTTP request to finish, and if we don't release the Active Record connection right away, other fibers won't be able to use it.
76
+ #
77
+ # Active Record 7.2 handles this case by using [#with_connection](https://api.rubyonrails.org/classes/ActiveRecord/ConnectionAdapters/ConnectionPool.html#method-i-with_connection) internally.
78
+ # With older Active Record versions, Rage handles this case on its own by keeping track of blocking calls and releasing Active Record connections between them.
42
79
  class Fiber
43
80
  # @private
44
81
  AWAIT_ERROR_MESSAGE = "err"
@@ -81,6 +118,9 @@ class Fiber
81
118
  "block:#{object_id}:#{@__block_channel_i}"
82
119
  end
83
120
 
121
+ # @private
122
+ attr_accessor :__awaited_fileno
123
+
84
124
  # @private
85
125
  # pause a fiber and resume in the next iteration of the event loop
86
126
  def self.pause
@@ -138,7 +178,7 @@ class Fiber
138
178
  end
139
179
  end
140
180
 
141
- Fiber.yield
181
+ Fiber.defer(-1)
142
182
  Iodine.defer { Iodine.unsubscribe("await:#{f.object_id}") }
143
183
 
144
184
  # if num_wait_for is not 0 means we exited prematurely because of an error
@@ -14,9 +14,9 @@ class Rage::FiberScheduler
14
14
  f = Fiber.current
15
15
  ::Iodine::Scheduler.attach(io.fileno, events, timeout&.ceil || 0) { |err| f.resume(err) }
16
16
 
17
- err = Fiber.defer
18
- if err == Errno::ETIMEDOUT::Errno
19
- 0
17
+ err = Fiber.defer(io.fileno)
18
+ if err && err < 0
19
+ err
20
20
  else
21
21
  events
22
22
  end
@@ -82,7 +82,7 @@ class Rage::Logger
82
82
  end
83
83
 
84
84
  @formatter = formatter
85
- @level = level
85
+ @level = @logdev ? level : Logger::UNKNOWN
86
86
  define_log_methods
87
87
  end
88
88
 
data/lib/rage/rails.rb CHANGED
@@ -44,7 +44,10 @@ end
44
44
  # clone Rails logger
45
45
  Rails.configuration.after_initialize do
46
46
  if Rails.logger && !Rage.logger
47
- rails_logdev = Rails.logger.instance_variable_get(:@logdev)
47
+ rails_logdev = Rails.logger.yield_self { |logger|
48
+ logger.respond_to?(:broadcasts) ? logger.broadcasts.last : logger
49
+ }.instance_variable_get(:@logdev)
50
+
48
51
  Rage.configure do
49
52
  config.logger = Rage::Logger.new(rails_logdev.dev) if rails_logdev.is_a?(Logger::LogDevice)
50
53
  end
data/lib/rage/request.rb CHANGED
@@ -37,6 +37,45 @@ class Rage::Request
37
37
  )
38
38
  end
39
39
 
40
+ # Returns the full URL of the request.
41
+ # @example
42
+ # request.url # => "https://example.com/users?show_archived=true"
43
+ def url
44
+ scheme = @env["rack.url_scheme"]
45
+ host = @env["SERVER_NAME"]
46
+ port = @env["SERVER_PORT"]
47
+ path = @env["PATH_INFO"]
48
+ query_string = @env["QUERY_STRING"]
49
+
50
+ port_part = (scheme == "http" && port == "80") || (scheme == "https" && port == "443") ? "" : ":#{port}"
51
+ query_part = query_string.empty? ? "" : "?#{query_string}"
52
+
53
+ "#{scheme}://#{host}#{port_part}#{path}#{query_part}"
54
+ end
55
+
56
+ # Returns the path of the request.
57
+ # @example
58
+ # request.path # => "/users"
59
+ def path
60
+ @env["PATH_INFO"]
61
+ end
62
+
63
+ # Returns the full path including the query string.
64
+ # @example
65
+ # request.fullpath # => "/users?show_archived=true"
66
+ def fullpath
67
+ path = @env["PATH_INFO"]
68
+ query_string = @env["QUERY_STRING"]
69
+ query_string.empty? ? path : "#{path}?#{query_string}"
70
+ end
71
+
72
+ # Returns the user agent of the request.
73
+ # @example
74
+ # request.user_agent # => "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36"
75
+ def user_agent
76
+ @env["HTTP_USER_AGENT"]
77
+ end
78
+
40
79
  private
41
80
 
42
81
  def if_none_match
data/lib/rage/setup.rb CHANGED
@@ -9,9 +9,9 @@ end
9
9
  # Run application initializers
10
10
  Dir["#{Rage.root}/config/initializers/**/*.rb"].each { |initializer| load(initializer) }
11
11
 
12
+ require "rage/ext/setup"
13
+
12
14
  # Load application classes
13
15
  Rage.code_loader.setup
14
16
 
15
17
  require_relative "#{Rage.root}/config/routes"
16
-
17
- require "rage/ext/setup"
data/lib/rage/tasks.rb ADDED
@@ -0,0 +1,33 @@
1
+ begin
2
+ require "standalone_migrations"
3
+ rescue LoadError
4
+ end
5
+
6
+ class Rage::Tasks
7
+ class << self
8
+ def init
9
+ load_db_tasks if defined?(StandaloneMigrations)
10
+ end
11
+
12
+ private
13
+
14
+ def load_db_tasks
15
+ StandaloneMigrations::Configurator.prepend(Module.new do
16
+ def configuration_file
17
+ @path ||= begin
18
+ @__tempfile = Tempfile.new
19
+ @__tempfile.write <<~YAML
20
+ config:
21
+ database: config/database.yml
22
+ YAML
23
+ @__tempfile.close
24
+
25
+ @__tempfile.path
26
+ end
27
+ end
28
+ end)
29
+
30
+ StandaloneMigrations::Tasks.load_tasks
31
+ end
32
+ end
33
+ end
@@ -1 +1,2 @@
1
1
  require_relative "config/application"
2
+ Rage.load_tasks
@@ -2,6 +2,9 @@ require "bundler/setup"
2
2
  require "rage"
3
3
  Bundler.require(*Rage.groups)
4
4
 
5
+ <% if @use_database -%>
6
+ require "active_record"
7
+ <% end -%>
5
8
  require "rage/all"
6
9
 
7
10
  Rage.configure do
@@ -1,11 +1,11 @@
1
1
  Rage.configure do
2
2
  # Specify the number of server processes to run. Defaults to number of CPU cores.
3
- # config.server.workers_count = ENV.fetch("WEB_CONCURRENCY", 1)
3
+ # config.server.workers_count = ENV.fetch("WEB_CONCURRENCY", 1).to_i
4
4
 
5
5
  # Specify the port the server will listen on.
6
6
  config.server.port = 3000
7
7
 
8
8
  # Specify the logger
9
- config.logger = Rage::Logger.new("log/production.log")
9
+ config.logger = Rage::Logger.new(STDOUT)
10
10
  config.log_level = Logger::INFO
11
11
  end
@@ -0,0 +1,3 @@
1
+ class ApplicationRecord < ActiveRecord::Base
2
+ self.abstract_class = true
3
+ end
@@ -0,0 +1,9 @@
1
+ # This file should ensure the existence of records required to run the application in every environment (production,
2
+ # development, test). The code here should be idempotent so that it can be executed at any point in every environment.
3
+ # The data can then be loaded with the bin/rails db:seed command (or created alongside the database with db:setup).
4
+ #
5
+ # Example:
6
+ #
7
+ # ["Action", "Comedy", "Drama", "Horror"].each do |genre_name|
8
+ # MovieGenre.find_or_create_by!(name: genre_name)
9
+ # end
@@ -0,0 +1,24 @@
1
+ ##
2
+ # MySQL. Versions 5.5.8 and up are supported.
3
+ #
4
+ default: &default
5
+ adapter: mysql2
6
+ encoding: utf8mb4
7
+ pool: <%%= ENV.fetch("DB_MAX_CONNECTIONS") { 5 } %>
8
+ username: root
9
+ password:
10
+ socket: /tmp/mysql.sock
11
+
12
+ development:
13
+ <<: *default
14
+ database: <%= @app_name %>_development
15
+
16
+ test:
17
+ <<: *default
18
+ database: <%= @app_name %>_test
19
+
20
+ production:
21
+ <<: *default
22
+ database: <%= @app_name %>_production
23
+ username: <%= @app_name %>
24
+ password: <%%= ENV["<%= @app_name.upcase %>_DATABASE_PASSWORD"] %>
@@ -0,0 +1,21 @@
1
+ ##
2
+ # PostgreSQL. Versions 9.3 and up are supported.
3
+ #
4
+ default: &default
5
+ adapter: postgresql
6
+ encoding: unicode
7
+ pool: <%%= ENV.fetch("DB_MAX_CONNECTIONS") { 5 } %>
8
+
9
+ development:
10
+ <<: *default
11
+ database: <%= @app_name %>_development
12
+
13
+ test:
14
+ <<: *default
15
+ database: <%= @app_name %>_test
16
+
17
+ production:
18
+ <<: *default
19
+ database: <%= @app_name %>_production
20
+ username: <%= @app_name %>
21
+ password: <%%= ENV["<%= @app_name.upcase %>_DATABASE_PASSWORD"] %>
@@ -0,0 +1,19 @@
1
+ ##
2
+ # SQLite. Versions 3.8.0 and up are supported.
3
+ #
4
+ default: &default
5
+ adapter: sqlite3
6
+ pool: <%%= ENV.fetch("DB_MAX_CONNECTIONS") { 5 } %>
7
+ timeout: 5000
8
+
9
+ development:
10
+ <<: *default
11
+ database: storage/development.sqlite3
12
+
13
+ test:
14
+ <<: *default
15
+ database: storage/test.sqlite3
16
+
17
+ production:
18
+ <<: *default
19
+ database: storage/production.sqlite3
@@ -0,0 +1,24 @@
1
+ ##
2
+ # MySQL. Versions 5.5.8 and up are supported.
3
+ #
4
+ default: &default
5
+ adapter: trilogy
6
+ encoding: utf8mb4
7
+ pool: <%%= ENV.fetch("DB_MAX_THREADS") { 5 } %>
8
+ username: root
9
+ password:
10
+ socket: /tmp/mysql.sock
11
+
12
+ development:
13
+ <<: *default
14
+ database: <%= @app_name %>_development
15
+
16
+ test:
17
+ <<: *default
18
+ database: <%= @app_name %>_test
19
+
20
+ production:
21
+ <<: *default
22
+ database: <%= @app_name %>_production
23
+ username: <%= @app_name %>
24
+ password: <%%= ENV["<%= @app_name.upcase %>_DATABASE_PASSWORD"] %>
File without changes
@@ -0,0 +1,2 @@
1
+ class <%= @model_name %> < ApplicationRecord
2
+ end
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.8.0"
4
+ VERSION = "1.10.0"
5
5
  end
data/lib/rage-rb.rb CHANGED
@@ -65,8 +65,10 @@ module Rage
65
65
  if is_connected
66
66
  puts "INFO: Patching ActiveRecord::ConnectionPool"
67
67
  Iodine.on_state(:on_start) do
68
- ActiveRecord::Base.connection_pool.extend(Rage::Ext::ActiveRecord::ConnectionPool)
69
- ActiveRecord::Base.connection_pool.__init_rage_extension
68
+ ActiveRecord::Base.connection_handler.connection_pool_list(:all).each do |pool|
69
+ pool.extend(Rage::Ext::ActiveRecord::ConnectionPool)
70
+ pool.__init_rage_extension
71
+ end
70
72
  end
71
73
  else
72
74
  puts "WARNING: DB connection is not established - can't patch ActiveRecord::ConnectionPool"
@@ -80,6 +82,10 @@ module Rage
80
82
  end
81
83
  end
82
84
 
85
+ def self.load_tasks
86
+ Rage::Tasks.init
87
+ end
88
+
83
89
  # @private
84
90
  def self.with_middlewares(app, middlewares)
85
91
  middlewares.reverse.inject(app) do |next_in_chain, (middleware, args, block)|
@@ -111,6 +117,7 @@ module Rage
111
117
  end
112
118
  end
113
119
 
120
+ autoload :Tasks, "rage/tasks"
114
121
  autoload :Cookies, "rage/cookies"
115
122
  autoload :Session, "rage/session"
116
123
  autoload :Cable, "rage/cable/cable"
data/rage.gemspec CHANGED
@@ -29,7 +29,8 @@ Gem::Specification.new do |spec|
29
29
 
30
30
  spec.add_dependency "thor", "~> 1.0"
31
31
  spec.add_dependency "rack", "~> 2.0"
32
- spec.add_dependency "rage-iodine", "~> 3.0"
32
+ spec.add_dependency "rage-iodine", "~> 4.0"
33
33
  spec.add_dependency "zeitwerk", "~> 2.6"
34
34
  spec.add_dependency "rack-test", "~> 2.1"
35
+ spec.add_dependency "rake", ">= 12.0"
35
36
  end
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.8.0
4
+ version: 1.10.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-08-06 00:00:00.000000000 Z
11
+ date: 2024-09-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: thor
@@ -44,14 +44,14 @@ dependencies:
44
44
  requirements:
45
45
  - - "~>"
46
46
  - !ruby/object:Gem::Version
47
- version: '3.0'
47
+ version: '4.0'
48
48
  type: :runtime
49
49
  prerelease: false
50
50
  version_requirements: !ruby/object:Gem::Requirement
51
51
  requirements:
52
52
  - - "~>"
53
53
  - !ruby/object:Gem::Version
54
- version: '3.0'
54
+ version: '4.0'
55
55
  - !ruby/object:Gem::Dependency
56
56
  name: zeitwerk
57
57
  requirement: !ruby/object:Gem::Requirement
@@ -80,6 +80,20 @@ dependencies:
80
80
  - - "~>"
81
81
  - !ruby/object:Gem::Version
82
82
  version: '2.1'
83
+ - !ruby/object:Gem::Dependency
84
+ name: rake
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '12.0'
90
+ type: :runtime
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '12.0'
83
97
  description:
84
98
  email:
85
99
  - rsamoi@icloud.com
@@ -145,6 +159,7 @@ files:
145
159
  - lib/rage/session.rb
146
160
  - lib/rage/setup.rb
147
161
  - lib/rage/sidekiq_session.rb
162
+ - lib/rage/tasks.rb
148
163
  - lib/rage/templates/Gemfile
149
164
  - lib/rage/templates/Rakefile
150
165
  - lib/rage/templates/app-controllers-application_controller.rb
@@ -155,8 +170,16 @@ files:
155
170
  - lib/rage/templates/config-initializers-.keep
156
171
  - lib/rage/templates/config-routes.rb
157
172
  - lib/rage/templates/config.ru
173
+ - lib/rage/templates/db-templates/app-models-application_record.rb
174
+ - lib/rage/templates/db-templates/db-seeds.rb
175
+ - lib/rage/templates/db-templates/mysql/config-database.yml
176
+ - lib/rage/templates/db-templates/postgresql/config-database.yml
177
+ - lib/rage/templates/db-templates/sqlite3/config-database.yml
178
+ - lib/rage/templates/db-templates/trilogy/config-database.yml
158
179
  - lib/rage/templates/lib-.keep
180
+ - lib/rage/templates/lib-tasks-.keep
159
181
  - lib/rage/templates/log-.keep
182
+ - lib/rage/templates/model-template/model.rb
160
183
  - lib/rage/templates/public-.keep
161
184
  - lib/rage/uploaded_file.rb
162
185
  - lib/rage/version.rb