rage-rb 1.9.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: d8b2423a2cb780fd3f8ffc4e76a6690fff37162836e3d2d64791c7f058b3d63f
4
- data.tar.gz: 86c2ee3464e5668e78409d7eae2e987c4e6a5aa54bea024c7cca694dd142019e
3
+ metadata.gz: 11dd7f4039089ea1f06eb54c332851c1942f7dc646f48f20a648b9504681d61d
4
+ data.tar.gz: ed28d28bcee87dbbb59dcc200540b7b7588d68b3ace8821c55a014005314974a
5
5
  SHA512:
6
- metadata.gz: cc810e46ff275e4595c2631f3cb5cbd19d38dd42885b393bb240ffb8b96ff6cb6f0f93015a461c469cbb88e13cbcc53288f385db590bd2562dc604a4d38b423e
7
- data.tar.gz: 6be5256d0c92fd495f6afa2ba3d9f6a08cfe6058429040f10d035594ad9a54d050241fa443029836042e7435681563f36bfee89b04eecaeb31055206adff41ef
6
+ metadata.gz: fa1d951dcd7a0cb63fbcef9801298266f4bf61f515977dd3c311e7ea563df9dfcc383db282386f997257d35d01695168880aa160437166105aa8e73ada028295
7
+ data.tar.gz: 7c8e571b08e8987ffec6dd31799382ad0c9da35b42fb7aa0988b146f3efac6e8cd35391410f1e2339191a58dbec81ee8bdb4f78728070fc0d4123a4e2e54f4f0
data/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
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
+
3
14
  ## [1.9.0] - 2024-08-24
4
15
 
5
16
  ### 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."
@@ -115,6 +155,57 @@ module Rage
115
155
  IRB.start
116
156
  end
117
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
+
118
209
  private
119
210
 
120
211
  def environment
@@ -126,30 +217,85 @@ module Rage
126
217
  end
127
218
 
128
219
  def set_env(options)
129
- 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
130
230
 
131
- # at this point we don't know whether the app is running in standalone or Rails mode;
132
- # we set both variables to make sure applications are running in the same environment;
133
- ENV["RAILS_ENV"] = ENV["RAGE_ENV"] if ENV["RAGE_ENV"] && ENV["RAILS_ENV"] != ENV["RAGE_ENV"]
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:") }
134
237
  end
135
238
  end
136
239
 
137
- class NewAppGenerator < Thor::Group
240
+ class CLINewAppGenerator < Thor::Group
138
241
  include Thor::Actions
139
242
  argument :path, type: :string
243
+ argument :database, type: :string, required: false
140
244
 
141
245
  def self.source_root
142
246
  File.expand_path("templates", __dir__)
143
247
  end
144
248
 
249
+ def setup
250
+ @use_database = !database.nil?
251
+ end
252
+
145
253
  def create_directory
146
254
  empty_directory(path)
147
255
  end
148
256
 
149
257
  def copy_files
150
- 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
+
151
284
  *template_path_parts, template_name = template.split("-")
152
- 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"
153
299
  end
154
300
  end
155
301
  end
@@ -130,9 +130,13 @@
130
130
  #
131
131
  # > Disables the `io_write` hook to fix the ["zero-length iov"](https://bugs.ruby-lang.org/issues/19640) error on Ruby < 3.3.
132
132
  #
133
- # • _RAGE_PATCH_AR_POOL_
133
+ # • _RAGE_DISABLE_AR_POOL_PATCH_
134
134
  #
135
- # > Enables the `ActiveRecord::ConnectionPool` patch to optimize database connection management. Use it to increase throughput under high load.
135
+ # > Disables the `ActiveRecord::ConnectionPool` patch and makes Rage use the original ActiveRecord implementation.
136
+ #
137
+ # • _RAGE_DISABLE_AR_WEAK_CONNECTIONS_
138
+ #
139
+ # > Instructs Rage to not reuse Active Record connections between different fibers.
136
140
  #
137
141
  class Rage::Configuration
138
142
  attr_accessor :logger
@@ -264,6 +268,22 @@ class Rage::Configuration
264
268
  class Internal
265
269
  attr_accessor :rails_mode
266
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
+
267
287
  def inspect
268
288
  "#<#{self.class.name}>"
269
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
@@ -212,11 +261,12 @@ module Rage::Ext::ActiveRecord::ConnectionPool
212
261
  end
213
262
 
214
263
  # create a new pool
264
+ self.automatic_reconnect = true
215
265
  @__connections = build_new_connections
216
266
 
217
267
  # notify blocked fibers that there are new connections available
218
268
  [@__blocked.length, @__connections.length].min.times do
219
- Iodine.publish("ext:ar-connection-released", "", Iodine::PubSub::PROCESS)
269
+ Iodine.publish(@release_connection_channel, "", Iodine::PubSub::PROCESS)
220
270
  end
221
271
  end
222
272
 
@@ -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"] && !Rage.env.test?
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/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.9.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.9.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-24 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