rage-rb 1.25.1 → 1.26.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: 13987c313721651366f9145e9b80d8a83f74c3b16346f2d79b83c3517566d3f7
4
- data.tar.gz: ec0c134f49ac88bdd99023b576e00cd057c84e231114d90a43f03d46471bbc55
3
+ metadata.gz: 7ad60525da974b198db62787784804444e9ad8cb768119a2eeea7f45aefaa93f
4
+ data.tar.gz: 8bac8c6ac175b486e61bf64a1ca574e590e14bcbacbec0e0da6de59d905de3b9
5
5
  SHA512:
6
- metadata.gz: 645c87d57e772def70e5c7e298e0383d66551e9ad7d55a3b9eb56e1789c98e119bca6b66e8eb98c7b938b1ae20122a7fad1ab4c508529d328b0436c2ddabe17b
7
- data.tar.gz: e1548c087c43f39767484247cb271f625275669f089a22b18e06a5a11a7e4dd39ffe47c6772117b39e62cf1b33c242b343a3b6aaff00bb3aff5501f3473d77a5
6
+ metadata.gz: 1bd4700b5265c58df651b026c546a2cbe452577bd5f225cdbdfd4f0334d857c024d3d22743fed4db4d98be3eb71ad30e656106ecae80c55cbbc4f0883f0ae619
7
+ data.tar.gz: 3033c718ad816739c81ff79fa825f662943ea02c9aeade6cd6c5bfb68be6800e9fa79c06d57c3430f623db9f0b41e015e921711bcdf407014e4c0fb62c96ba20
data/Appraisals CHANGED
@@ -1,7 +1,3 @@
1
- appraise "active_record_7_0" do
2
- gem "activerecord", "~> 7.0.0"
3
- end
4
-
5
1
  appraise "active_record_7_1" do
6
2
  gem "activerecord", "~> 7.1.0"
7
3
  end
data/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.26.0] - 2026-07-07
4
+
5
+ ### Added
6
+
7
+ - [OpenAPI] Add support for blueprint inheritance. Child blueprints now inherit fields from parent blueprints.
8
+ - [OpenAPI] Refactor `Rage::OpenAPI::Parsers::Ext::Blueprinter` using live reflection instead of Prism AST traversal to simplify schema extraction
9
+ - Connection pool improvements (#301).
10
+ - [OpenAPI] Add Blueprinter association parsing with cycle detection (#291)
11
+ - [OpenAPI] Add support for the `view:` option in Blueprinter parser.
12
+ - Add `Rage::Daemon` (#339).
13
+ - Enable non-blocking process monitoring (#341).
14
+ - Allow to suppress FiberWrapper warnings (#347).
15
+ - Add the Extension API (#344).
16
+
17
+ ### Fixed
18
+
19
+ - [CI] Check all changed PR file pages when requiring changelog updates.
20
+ - [API] Ignore `If-Modified-Since` when `If-None-Match` is present.
21
+ - [API] Use weak comparison for `If-None-Match` validation.
22
+ - [Deferred] Ignore missing temp files during async disk storage cleanup.
23
+ - [Request] Treat IPv6 literals as non-domain hosts.
24
+ - [Router] Fall back to `SERVER_NAME` when deriving exact host constraints.
25
+ - [Cookies] Use request host fallback when resolving cookie domains.
26
+ - [Cookies] Match array-based cookie domains case-insensitively.
27
+ - [API] Reject invalid status symbols in `head` and `render`.
28
+
3
29
  ## [1.25.1] - 2026-06-08
4
30
 
5
31
  ### Fixed
data/Gemfile CHANGED
@@ -26,4 +26,5 @@ group :test do
26
26
  gem "prism"
27
27
  gem "redis-client"
28
28
  gem "appraisal", "~> 2.5"
29
+ gem "blueprinter"
29
30
  end
data/Rakefile CHANGED
@@ -17,6 +17,9 @@ task :appraise do |_, args|
17
17
  puts ">> Appraising #{ext_version}"
18
18
 
19
19
  gem_name = ext_version.sub(/_\d+(_\d+)*$/, "")
20
- system "bundle exec appraisal #{ext_version} rspec spec/ext/#{gem_name}/"
20
+
21
+ Pathname.new("spec/ext").join(gem_name).glob("*_spec.rb").each do |spec|
22
+ system("bundle exec appraisal #{ext_version} rspec #{spec}", exception: true)
23
+ end
21
24
  end
22
25
  end
@@ -289,6 +289,14 @@ class Rage::Configuration
289
289
  end
290
290
  # @!endgroup
291
291
 
292
+ # @!group Daemons
293
+ # Allows configuring daemon settings.
294
+ # @return [Rage::Configuration::Daemons]
295
+ def daemons
296
+ @daemons ||= Daemons.new
297
+ end
298
+ # @!endgroup
299
+
292
300
  # @private
293
301
  def pubsub
294
302
  @pubsub ||= PubSub.new
@@ -630,12 +638,36 @@ class Rage::Configuration
630
638
  def initialize
631
639
  super
632
640
  @objects = [[Rage::FiberWrapper]]
641
+ @suppress_fiber_wrapper_warning = false
642
+ end
643
+
644
+ # Suppress warnings when inserting middleware before {Rage::FiberWrapper}.
645
+ #
646
+ # By default, Rage warns when middleware is inserted before {Rage::FiberWrapper} because such middleware
647
+ # runs outside the fiber-based request context. This means the middleware won't benefit from Rage's
648
+ # non-blocking I/O scheduling and must handle the custom response format.
649
+ #
650
+ # Use this method when you intentionally want middleware to run outside the request fiber, for example,
651
+ # to serve static assets without the overhead of creating a fiber for each request.
652
+ #
653
+ # @yield The block within which middleware insertion warnings are suppressed
654
+ # @example Suppress warnings when inserting a static file server
655
+ # Rage.configure do
656
+ # config.middleware.allow_outside_request_fiber! do
657
+ # config.middleware.insert_before 0, MyStaticFileServer
658
+ # end
659
+ # end
660
+ def allow_outside_request_fiber!
661
+ @suppress_fiber_wrapper_warning = true
662
+ yield
663
+ ensure
664
+ @suppress_fiber_wrapper_warning = false
633
665
  end
634
666
 
635
667
  private
636
668
 
637
669
  def validate!(index, middleware)
638
- if index == 0 && @objects[0][0] == Rage::FiberWrapper
670
+ if index == 0 && @objects[0][0] == Rage::FiberWrapper && !@suppress_fiber_wrapper_warning
639
671
  puts "WARNING: inserting the `#{middleware}` middleware before `Rage::FiberWrapper` may cause undefined behavior."
640
672
  end
641
673
  end
@@ -1115,6 +1147,48 @@ class Rage::Configuration
1115
1147
  end
1116
1148
  end
1117
1149
 
1150
+ class Daemons
1151
+ # @private
1152
+ def initialize
1153
+ @klasses = []
1154
+ end
1155
+
1156
+ # @private
1157
+ attr_reader :klasses
1158
+
1159
+ # Register a new daemon.
1160
+ # @param daemon [Class]
1161
+ # @example
1162
+ # Rage.configure do
1163
+ # config.daemons << FileWatcher
1164
+ # end
1165
+ def <<(daemon)
1166
+ validate!(daemon)
1167
+ @klasses << daemon
1168
+ end
1169
+
1170
+ alias_method :push, :<<
1171
+
1172
+ # Remove a registered daemon.
1173
+ # @param daemon [Class]
1174
+ # @example
1175
+ # Rage.configure do
1176
+ # config.daemons.delete(FileWatcher)
1177
+ # end
1178
+ def delete(daemon)
1179
+ validate!(daemon)
1180
+ @klasses.delete(daemon)
1181
+ end
1182
+
1183
+ private
1184
+
1185
+ def validate!(klass)
1186
+ if !klass.is_a?(Class) || !klass.ancestors.include?(Rage::Daemon)
1187
+ raise ArgumentError, "Cannot add `#{klass}` as a daemon; should inherit `Rage::Daemon`"
1188
+ end
1189
+ end
1190
+ end
1191
+
1118
1192
  # @private
1119
1193
  class PubSub
1120
1194
  attr_reader :adapter
@@ -499,13 +499,7 @@ class RageController::API
499
499
  @__status = 200
500
500
  end
501
501
 
502
- if status
503
- @__status = if status.is_a?(Symbol)
504
- ::Rack::Utils::SYMBOL_TO_STATUS_CODE[status]
505
- else
506
- status
507
- end
508
- end
502
+ @__status = ::Rack::Utils.status_code(status) if status
509
503
 
510
504
  if sse
511
505
  raise ArgumentError, "Cannot render both a standard body and an SSE stream." unless @__body.empty?
@@ -531,12 +525,7 @@ class RageController::API
531
525
  # head 429
532
526
  def head(status)
533
527
  @__rendered = true
534
-
535
- @__status = if status.is_a?(Symbol)
536
- ::Rack::Utils::SYMBOL_TO_STATUS_CODE[status]
537
- else
538
- status
539
- end
528
+ @__status = ::Rack::Utils.status_code(status)
540
529
  end
541
530
 
542
531
  # Set response headers.
data/lib/rage/cookies.rb CHANGED
@@ -201,14 +201,14 @@ class Rage::Cookies
201
201
  end
202
202
 
203
203
  if (domain = value[:domain])
204
- host = @env["HTTP_HOST"]&.sub(/:\d+\z/, "")
204
+ host = Rack::Request.new(@env).host
205
205
 
206
206
  processed_domain = if domain.is_a?(String)
207
207
  domain
208
208
  elsif domain == :all
209
209
  DomainName(host).domain
210
210
  elsif domain.is_a?(Array)
211
- host if domain.include?(host)
211
+ domain.find { |candidate| candidate.casecmp?(host) } if host
212
212
  end
213
213
  end
214
214
 
@@ -0,0 +1,240 @@
1
+ # frozen_string_literal: true
2
+
3
+ ##
4
+ # `Rage::Daemon` is an abstraction for running long-lived background processes alongside your application.
5
+ # Use daemons for tasks that need to run continuously, such as listening to message queues, consuming streaming APIs,
6
+ # or maintaining persistent connections to external services.
7
+ #
8
+ # The framework automatically manages daemon lifecycle:
9
+ # - Daemons are started when the server boots
10
+ # - If a daemon exits or crashes, Rage restarts it with exponential backoff
11
+ # - If a worker process dies, Rage restarts the daemon in another worker
12
+ # - On server shutdown, daemons are gracefully stopped
13
+ #
14
+ # ## Defining a Daemon
15
+ #
16
+ # Create a daemon by subclassing `Rage::Daemon` and implementing the {#perform} method:
17
+ #
18
+ # ```ruby
19
+ # class RedisListener < Rage::Daemon
20
+ # def perform
21
+ # redis = Redis.new
22
+ #
23
+ # redis.subscribe("notifications") do |on|
24
+ # on.message do |channel, message|
25
+ # Rage::Cable.broadcast("notifications", message)
26
+ # end
27
+ # end
28
+ # end
29
+ # end
30
+ # ```
31
+ #
32
+ # ## Registering Daemons
33
+ #
34
+ # Register daemons in your application configuration:
35
+ #
36
+ # ```ruby
37
+ # # config/application.rb
38
+ # Rage.configure do
39
+ # config.daemons << RedisListener
40
+ # end
41
+ # ```
42
+ #
43
+ # ## Daemon Scope
44
+ #
45
+ # By default, daemons run in exactly one worker process per server. Use {scope scope} to change this behavior:
46
+ #
47
+ # ```ruby
48
+ # class MetricsCollector < Rage::Daemon
49
+ # scope :worker
50
+ #
51
+ # def perform
52
+ # # runs in every worker process
53
+ # end
54
+ # end
55
+ # ```
56
+ #
57
+ # ## Stopping a Daemon
58
+ #
59
+ # Return {Stop} from {#perform} to explicitly stop a daemon without triggering a restart:
60
+ #
61
+ # ```ruby
62
+ # class BatchProcessor < Rage::Daemon
63
+ # def perform
64
+ # while (batch = Batch.next_pending)
65
+ # process(batch)
66
+ # end
67
+ #
68
+ # Stop # all batches processed, stop the daemon
69
+ # end
70
+ # end
71
+ # ```
72
+ #
73
+ # ## Cleanup
74
+ #
75
+ # Implement the `cleanup` method to release resources when a daemon stops or restarts:
76
+ #
77
+ # ```ruby
78
+ # class StreamConsumer < Rage::Daemon
79
+ # def perform
80
+ # @connection = StreamingAPI.connect
81
+ # @connection.each { |event| process(event) }
82
+ # end
83
+ #
84
+ # def cleanup
85
+ # @connection&.close
86
+ # end
87
+ # end
88
+ # ```
89
+ #
90
+ # The `cleanup` method is called:
91
+ # - When {#perform} returns normally
92
+ # - When {#perform} raises an exception (before restart)
93
+ # - When the server is shutting down
94
+ #
95
+ class Rage::Daemon
96
+ # A sentinel value that can be returned from {#perform} to explicitly stop the daemon.
97
+ # When returned, the daemon will not be restarted.
98
+ # @example
99
+ # def perform
100
+ # return Stop if shutdown_requested?
101
+ # # ...
102
+ # end
103
+ Stop = Object.new
104
+
105
+ INITIAL_BACKOFF = 0.1
106
+ private_constant :INITIAL_BACKOFF
107
+
108
+ MAX_BACKOFF = 30
109
+ private_constant :MAX_BACKOFF
110
+
111
+ BACKOFF_RESET_INTERVAL = 30
112
+ private_constant :BACKOFF_RESET_INTERVAL
113
+
114
+ class << self
115
+ # @private
116
+ attr_accessor :__level
117
+
118
+ # Configures the scope at which the daemon runs.
119
+ #
120
+ # By default, daemons run in one worker process per server (`:node` scope).
121
+ # Use this method to change the scope when a different behavior is needed.
122
+ #
123
+ # @param level [Symbol] the scope level
124
+ # @option level :node One instance per server (default). Rage uses IPC to coordinate
125
+ # which worker runs the daemon. If that worker dies, the daemon restarts in another worker.
126
+ # @option level :worker One instance per worker process. Use this when the daemon performs
127
+ # work that needs to run in parallel across workers.
128
+ #
129
+ # @example Running in every worker
130
+ # class MetricsCollector < Rage::Daemon
131
+ # scope :worker
132
+ #
133
+ # def perform
134
+ # # runs in every worker process
135
+ # end
136
+ # end
137
+ def scope(level)
138
+ unless level == :worker || level == :node
139
+ raise ArgumentError, "Invalid scope level: #{level.inspect}. Valid options are :node and :worker."
140
+ end
141
+
142
+ @__level = level
143
+ end
144
+
145
+ # @private
146
+ def inherited(klass)
147
+ klass.__level = __level
148
+ end
149
+
150
+ # @private
151
+ def __perform
152
+ if __level.nil? || __level == :node
153
+ Rage::Internal.pick_a_worker(purpose: "daemon-#{name}") { __execute }
154
+ else
155
+ __execute
156
+ end
157
+ end
158
+
159
+ private
160
+
161
+ def __execute
162
+ Iodine.on_state(:start_shutdown) do
163
+ @__stopping = true
164
+ end
165
+
166
+ Fiber.schedule do
167
+ backoff = INITIAL_BACKOFF
168
+
169
+ Rage.logger.with_context(daemon: name) do
170
+ loop do
171
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
172
+
173
+ begin
174
+ instance = new
175
+ result = instance.perform
176
+ break if result.equal?(Stop) || @__stopping
177
+ Rage.logger.warn("Daemon exited, restarting...")
178
+ rescue => e
179
+ break if @__stopping
180
+ Rage.logger.error("Daemon failed with exception: #{e.class} (#{e.message}):\n#{e.backtrace.join("\n")}")
181
+ Rage::Errors.report(e)
182
+ ensure
183
+ __cleanup(instance)
184
+ end
185
+
186
+ # reset backoff if ran successfully for a while
187
+ backoff = INITIAL_BACKOFF if Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at > BACKOFF_RESET_INTERVAL
188
+ sleep(backoff / 2 + rand * backoff / 2)
189
+ backoff = (backoff * 2).clamp(INITIAL_BACKOFF, MAX_BACKOFF)
190
+ end
191
+ end
192
+ end
193
+ end # class << self
194
+
195
+ def __cleanup(instance)
196
+ instance.cleanup if instance.respond_to?(:cleanup)
197
+ rescue => e
198
+ Rage.logger.error("Cleanup hook failed with exception: #{e.class} (#{e.message}):\n#{e.backtrace.join("\n")}")
199
+ Rage::Errors.report(e)
200
+ end
201
+ end
202
+
203
+ # The main entry point for the daemon's work.
204
+ #
205
+ # Implement this method to define what the daemon does. The method should contain
206
+ # the daemon's main loop or blocking operation. When this method returns normally,
207
+ # Rage logs a warning and restarts the daemon. Return {Stop} to explicitly stop
208
+ # the daemon without triggering a restart.
209
+ #
210
+ # @return [Object] return {Stop} to stop the daemon; any other return value triggers a restart
211
+ # @example Listening to a Redis channel
212
+ # def perform
213
+ # redis = Redis.new
214
+ # redis.subscribe("events") do |on|
215
+ # on.message { |_, msg| handle(msg) }
216
+ # end
217
+ # end
218
+ # @example Polling a queue
219
+ # def perform
220
+ # loop do
221
+ # message = queue.pop
222
+ # process(message)
223
+ # end
224
+ # end
225
+ # @example Processing until complete
226
+ # def perform
227
+ # while (item = fetch_next_item)
228
+ # process(item)
229
+ # end
230
+ #
231
+ # Stop
232
+ # end
233
+ def perform
234
+ end
235
+ end
236
+
237
+ Iodine.on_state(:on_start) do
238
+ Fiber.set_scheduler(Rage::FiberScheduler.new) unless Fiber.scheduler
239
+ Rage.config.daemons.klasses.each { |klass| klass.__perform }
240
+ end
@@ -241,8 +241,7 @@ class Rage::Deferred::Backends::Disk
241
241
 
242
242
  # delete the old storage ensuring the copied data has already been written to disk
243
243
  Iodine.run_after(@fsync_frequency) do
244
- old_storage.close
245
- File.unlink(old_storage.path)
244
+ cleanup_storage(old_storage)
246
245
  end
247
246
  end
248
247
 
@@ -267,8 +266,13 @@ class Rage::Deferred::Backends::Disk
267
266
  end
268
267
 
269
268
  Iodine.run_after(@fsync_frequency) do
270
- storage.close
271
- File.unlink(storage.path)
269
+ cleanup_storage(storage)
272
270
  end
273
271
  end
272
+
273
+ def cleanup_storage(storage)
274
+ path = storage.path
275
+ storage.close
276
+ File.unlink(path) if File.exist?(path)
277
+ end
274
278
  end
@@ -11,7 +11,11 @@ module Rage::Ext::ActiveRecord::ConnectionPool
11
11
  @arr << el
12
12
  end
13
13
 
14
- def shift
14
+ def unshift(el)
15
+ @arr.unshift(el)
16
+ end
17
+
18
+ def pop
15
19
  nil
16
20
  end
17
21
 
@@ -59,18 +63,27 @@ module Rage::Ext::ActiveRecord::ConnectionPool
59
63
  # in the format of { Fiber => Connection }
60
64
  @__in_use = {}
61
65
 
62
- # a list of all DB connections that are currently idle
63
- @__connections = build_new_connections
64
-
65
66
  # how long a fiber can wait for a connection to become available
66
67
  @__checkout_timeout = checkout_timeout
67
68
 
68
69
  # how long a connection can be idle for before disconnecting
69
70
  @__idle_timeout = respond_to?(:db_config) ? db_config.idle_timeout : @idle_timeout
70
71
 
72
+ # pool will maintain at least this many connections
73
+ @__min_connections = respond_to?(:min_connections) ? min_connections : 0
74
+
75
+ # connections older than this are automatically disconnected
76
+ @__max_age = respond_to?(:max_age) ? max_age : Float::INFINITY
77
+
78
+ # seconds between keepalive pings on idle connections
79
+ @__keepalive = respond_to?(:keepalive) ? keepalive : nil
80
+
71
81
  # how often should we check for fibers that wait for a connection for too long
72
82
  @__timeout_worker_frequency = 0.5
73
83
 
84
+ # a list of all DB connections that are currently idle
85
+ @__connections = build_new_connections
86
+
74
87
  # reject fibers that wait for a connection for more than `@__checkout_timeout`
75
88
  Iodine.run_every((@__timeout_worker_frequency * 1_000).to_i) do
76
89
  if @__blocked.length > 0
@@ -105,6 +118,8 @@ module Rage::Ext::ActiveRecord::ConnectionPool
105
118
  end
106
119
 
107
120
  @release_connection_channel = "ext:ar-connection-released:#{object_id}"
121
+ @__owner_thread = Thread.current
122
+ @__background_maintenance_disabled = (ActiveRecord.version < Gem::Version.create("8.1"))
108
123
 
109
124
  # resume blocked fibers once connections become available
110
125
  Iodine.subscribe(@release_connection_channel) do
@@ -118,6 +133,14 @@ module Rage::Ext::ActiveRecord::ConnectionPool
118
133
  Iodine.on_state(:on_finish) do
119
134
  Iodine.unsubscribe(@release_connection_channel)
120
135
  end
136
+
137
+ Iodine.run_every((db_config.reaping_frequency * 1_000).to_i) do
138
+ reap
139
+ flush
140
+ disconnected = retire_old_connections
141
+ keep_alive
142
+ preconnect if disconnected
143
+ end
121
144
  end
122
145
 
123
146
  # Returns true if there is an open connection being used for the current fiber.
@@ -127,12 +150,10 @@ module Rage::Ext::ActiveRecord::ConnectionPool
127
150
 
128
151
  # Retrieve the connection associated with the current fiber, or obtain one if necessary.
129
152
  def connection
130
- @__in_use[Fiber.current] ||= @__connections.shift || begin
131
- fiber, blocked_since = Fiber.current, Process.clock_gettime(Process::CLOCK_MONOTONIC)
132
- @__blocked[fiber] = blocked_since
153
+ @__in_use[Fiber.current] ||= @__connections.pop || begin
154
+ @__blocked[Fiber.current] = Process.clock_gettime(Process::CLOCK_MONOTONIC)
133
155
  Fiber.yield
134
-
135
- @__connections.shift
156
+ @__connections.pop
136
157
  end
137
158
  end
138
159
 
@@ -149,45 +170,125 @@ module Rage::Ext::ActiveRecord::ConnectionPool
149
170
 
150
171
  # Recover lost connections for the pool.
151
172
  def reap
152
- crashed_fibers = nil
173
+ return unless Thread.current == @__owner_thread
153
174
 
154
- @__in_use.each do |fiber, conn|
175
+ dead = nil
176
+
177
+ @__in_use.delete_if do |fiber, conn|
155
178
  unless fiber.alive?
179
+ (dead ||= []) << [fiber, conn]
180
+ true
181
+ end
182
+ end
183
+
184
+ dead&.each do |fiber, conn|
185
+ Fiber.schedule do
156
186
  if conn.active?
157
187
  conn.reset!
158
- (crashed_fibers ||= []) << fiber
188
+ @__in_use[fiber] = conn
189
+ release_connection(fiber)
159
190
  else
160
- @__in_use.delete(fiber)
161
191
  conn.disconnect!
162
192
  __remove__(conn)
163
193
  self.automatic_reconnect = true
164
- @__connections += build_new_connections(1)
194
+ @__connections.unshift(*build_new_connections(1))
165
195
  Iodine.publish(@release_connection_channel, "", Iodine::PubSub::PROCESS) if @__blocked.length > 0
166
196
  end
167
197
  end
168
198
  end
169
-
170
- if crashed_fibers
171
- crashed_fibers.each { |fiber| release_connection(fiber) }
172
- end
173
199
  end
174
200
 
175
201
  # Disconnect all connections that have been idle for at least
176
202
  # `minimum_idle` seconds. Connections currently checked out, or that were
177
203
  # checked in less than `minimum_idle` seconds ago, are unaffected.
178
204
  def flush(minimum_idle = @__idle_timeout)
179
- return if minimum_idle.nil? || @__connections.length == 0
205
+ return if Thread.current != @__owner_thread || minimum_idle.nil? || @__connections.length == 0
180
206
 
181
- current_time, i = Process.clock_gettime(Process::CLOCK_MONOTONIC), 0
182
- while i < @__connections.length
207
+ current_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
208
+ i = @__connections.length - 1
209
+ connections_to_preserve = @__min_connections
210
+
211
+ while i >= 0
183
212
  conn = @__connections[i]
213
+
214
+ if connections_to_preserve > 0 && conn.connected? && conn.verified?
215
+ connections_to_preserve -= 1
216
+ i -= 1
217
+ next
218
+ end
219
+
184
220
  if conn.__idle_since && current_time - conn.__idle_since >= minimum_idle
185
221
  conn.__idle_since = nil
186
222
  conn.__needs_reconnect = true
187
223
  conn.disconnect!
188
224
  end
225
+ i -= 1
226
+ end
227
+ end
228
+
229
+ # Disconnect connections exceeding `max_age`
230
+ def retire_old_connections(max_age = @__max_age)
231
+ return false if max_age.nil? || max_age.infinite?
232
+
233
+ i, disconnected = 0, false
234
+
235
+ while i < @__connections.length
236
+ conn = @__connections[i]
237
+ if (conn.connection_age || 0) >= conn.pool_jitter(max_age)
238
+ conn.disconnect!
239
+ disconnected = true
240
+ end
189
241
  i += 1
190
242
  end
243
+
244
+ disconnected
245
+ end
246
+
247
+ # Ping idle connections to prevent firewall/server timeouts
248
+ def keep_alive(threshold = @__keepalive)
249
+ return if threshold.nil? || @__connections.length == 0
250
+
251
+ to_ping = nil
252
+
253
+ @__connections.delete_if do |conn|
254
+ if (conn.seconds_since_last_activity || 0) >= conn.pool_jitter(threshold)
255
+ (to_ping ||= []) << conn
256
+ true
257
+ end
258
+ end
259
+
260
+ to_ping&.each do |conn|
261
+ Fiber.schedule do
262
+ if conn.active?
263
+ @__connections << conn
264
+ else
265
+ conn.disconnect!
266
+ @__connections.unshift(conn)
267
+ end
268
+ Iodine.publish(@release_connection_channel, "", Iodine::PubSub::PROCESS) if @__blocked.length > 0
269
+ end
270
+ end
271
+ end
272
+
273
+ # Proactively establish DB connections
274
+ def preconnect
275
+ return if @__connections.length == 0 || @__background_maintenance_disabled
276
+
277
+ active_connections_count = @__in_use.length + @__connections.count { |conn| conn.connected? && conn.verified? }
278
+
279
+ while @__min_connections - active_connections_count > 0
280
+ i = @__connections.rindex { |conn| !conn.connected? || !conn.verified? }
281
+ return unless i
282
+
283
+ Fiber.schedule do
284
+ conn = @__connections.delete_at(i)
285
+ conn.connect! rescue nil
286
+ @__connections << conn
287
+ Iodine.publish(@release_connection_channel, "", Iodine::PubSub::PROCESS) if @__blocked.length > 0
288
+ end
289
+
290
+ active_connections_count += 1
291
+ end
191
292
  end
192
293
 
193
294
  # Disconnect all currently idle connections. Connections currently checked out are unaffected.
@@ -234,6 +335,8 @@ module Rage::Ext::ActiveRecord::ConnectionPool
234
335
  # Raises `ActiveRecord::ExclusiveConnectionTimeoutError` if unable to gain ownership of all
235
336
  # connections in the pool within a timeout interval (default duration is `checkout_timeout * 2` seconds).
236
337
  def disconnect(raise_on_acquisition_timeout = true, disconnect_attempts = 0)
338
+ return if @__connections.is_a?(BlackHoleList)
339
+
237
340
  # allow request fibers to release connections, but block from acquiring new ones
238
341
  if disconnect_attempts == 0
239
342
  @__connections = BlackHoleList.new(@__connections)
@@ -288,6 +391,10 @@ module Rage::Ext::ActiveRecord::ConnectionPool
288
391
  connection
289
392
  end
290
393
 
394
+ def maintainable?
395
+ false
396
+ end
397
+
291
398
  # Check in a database connection back into the pool, indicating that you no longer need this connection.
292
399
  def checkin(conn)
293
400
  fiber = @__in_use.key(conn)
@@ -328,8 +435,12 @@ module Rage::Ext::ActiveRecord::ConnectionPool
328
435
  private
329
436
 
330
437
  def build_new_connections(num_connections = size)
331
- (1..num_connections).map do
438
+ connections = (1..num_connections).map do
332
439
  __checkout__.tap { |conn| conn.__idle_since = Process.clock_gettime(Process::CLOCK_MONOTONIC) }
333
440
  end
441
+
442
+ Iodine.defer { preconnect }
443
+
444
+ connections
334
445
  end
335
446
  end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ # @private
4
+ # This needs much more work to make it public, but it's already enough for inertia-rage.
5
+ class Rage::Extension
6
+ class << self
7
+ def initializer(id = name, before: nil, after: nil, &block)
8
+ __initializers[id] << block
9
+ end
10
+
11
+ def configure(&block)
12
+ __configurations << block
13
+ end
14
+
15
+ def before_server_start(&block)
16
+ Iodine.on_state(:pre_start, &block)
17
+ end
18
+
19
+ def __initializers
20
+ @@initializers ||= Hash.new { |h, k| h[k] = [] }
21
+ end
22
+
23
+ def __configurations
24
+ @@configurations ||= []
25
+ end
26
+ end
27
+ end
@@ -152,6 +152,11 @@ class Rage::FiberScheduler
152
152
  end
153
153
  end
154
154
 
155
+ # Wait for a specified process.
156
+ def process_wait(pid, flags)
157
+ Thread.new { Process::Status.wait(pid, flags) }.value
158
+ end
159
+
155
160
  # Create and schedule a new non-blocking fiber, handling request and user-spawned fibers differently.
156
161
  def fiber(&block)
157
162
  parent = Fiber.current
@@ -1,9 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Rage::OpenAPI::Parsers::Ext::Blueprinter
4
+ class InvalidViewError < StandardError; end
5
+
4
6
  def initialize(namespace: Object, root: Rage::OpenAPI::Nodes::Root.new, **)
5
7
  @namespace = namespace
6
8
  @root = root
9
+ @parsing_stack = Set.new
7
10
  end
8
11
 
9
12
  def known_definition?(str)
@@ -14,97 +17,71 @@ class Rage::OpenAPI::Parsers::Ext::Blueprinter
14
17
  end
15
18
 
16
19
  def parse(klass_str)
17
- visitor = __parse(klass_str)
18
- visitor.build_schema
19
- end
20
-
21
- def __parse(klass_str)
22
- is_collection, klass_str, _ = Rage::OpenAPI.__parse_serializer_args(klass_str)
20
+ is_collection, raw_klass_str, serializer_options = Rage::OpenAPI.__parse_serializer_args(klass_str)
21
+ klass = @namespace.const_get(raw_klass_str)
22
+ schema = build_schema(klass, is_collection, serializer_options)
23
23
 
24
- klass = @namespace.const_get(klass_str)
25
- source_path, _ = Object.const_source_location(klass.name)
26
- ast = Prism.parse_file(source_path)
27
-
28
- visitor = Visitor.new(self, is_collection)
29
- ast.value.accept(visitor)
24
+ if @root.schema_registry.key?(raw_klass_str)
25
+ @root.schema_registry[raw_klass_str] = is_collection ? schema["items"] : schema
26
+ end
30
27
 
31
- visitor
28
+ schema
29
+ rescue InvalidViewError => e
30
+ Rage::OpenAPI.__log_warn e.message
32
31
  end
33
32
 
34
- class VisitorContext
35
- attr_accessor :symbols, :keywords, :strings
33
+ private
36
34
 
37
- def initialize
38
- @symbols = []
39
- @strings = []
40
- @keywords = {}
41
- end
42
- end
35
+ def build_schema(klass, is_collection, serializer_options = nil)
36
+ @parsing_stack.add(klass.name)
43
37
 
44
- class Visitor < Prism::Visitor
45
- attr_accessor :schema
38
+ view_name = serializer_options&.key?(:view) ? serializer_options[:view] : :default
39
+ reflections = klass.reflections
40
+ view = reflections[view_name]
41
+ raise InvalidViewError, "invalid view #{view_name}" unless view
46
42
 
47
- def initialize(parser, is_collection)
48
- @parser = parser
49
- @is_collection = is_collection
43
+ identifier_fields = extract_fields(reflections[:identifier])
44
+ default_fields = extract_fields(view)
45
+ association_fields = extract_associations(view)
50
46
 
51
- @context = nil
52
- @schema = {}
53
- @segment = @schema
54
- @identifier = {}
55
- end
47
+ @parsing_stack.delete(klass.name)
56
48
 
57
- def build_schema
58
- result = { "type" => "object" }
49
+ schema = identifier_fields.merge(default_fields.merge(association_fields).sort.to_h)
59
50
 
60
- properties = {}
61
- properties.merge!(@identifier)
62
- properties.merge!(@schema.sort.to_h)
51
+ result = { "type" => "object" }
52
+ result["properties"] = schema if schema.any?
53
+ result = { "type" => "array", "items" => result } if is_collection
54
+ result
55
+ end
63
56
 
64
- result["properties"] = properties if properties.any?
65
- result = { "type" => "array", "items" => result } if @is_collection
66
- result
57
+ def extract_fields(view)
58
+ view.fields.each_with_object({}) do |(_, field), properties|
59
+ properties[field.display_name.to_s] = { "type" => "string" }
67
60
  end
61
+ end
68
62
 
69
- def visit_call_node(node)
70
- case node.name
71
- when :identifier
72
- context = with_context { visit(node.arguments) }
73
- @identifier[context.symbols.first] = { "type" => "string" }
74
-
75
- when :fields, :field
76
- context = with_context { visit(node.arguments) }
77
-
78
- if context.keywords["name"]
79
- @segment[context.keywords["name"]] = { "type" => "string" }
80
- elsif node.block
81
- @segment[context.symbols.first] = { "type" => "string" } if context.symbols.first
82
- @segment[context.strings.first] = { "type" => "string" } if context.strings.first
83
- else
84
- context.symbols.each { |symbol| @segment[symbol] = { "type" => "string" } }
85
- context.strings.each { |string| @segment[string] = { "type" => "string" } }
86
- end
63
+ def extract_associations(view)
64
+ view.associations.each_with_object({}) do |(_, association), properties|
65
+ blueprint = association.blueprint
66
+ name, display_name = association.name.to_s, association.display_name.to_s
67
+ is_collection = collection_association?(name)
68
+
69
+ item_schema = if blueprint.is_a?(Proc)
70
+ { "type" => "object" }
71
+ elsif @parsing_stack.include?(blueprint.name)
72
+ @root.schema_registry[blueprint.name] ||= nil
73
+ { "$ref" => "#/components/schemas/#{blueprint.name}" }
74
+ else
75
+ build_schema(blueprint, false)
87
76
  end
88
- end
89
-
90
- def visit_assoc_node(node)
91
- @context.keywords[node.key.value] = node.value.unescaped
92
- end
93
77
 
94
- def visit_symbol_node(node)
95
- @context.symbols << node.value
96
- end
97
-
98
- def visit_string_node(node)
99
- @context.strings << node.unescaped
78
+ properties[display_name] = is_collection ? { "type" => "array", "items" => item_schema } : item_schema
100
79
  end
80
+ end
101
81
 
102
- private
82
+ def collection_association?(name)
83
+ return true unless name.respond_to?(:singularize)
103
84
 
104
- def with_context
105
- @context = VisitorContext.new
106
- yield
107
- @context
108
- end
85
+ name.singularize != name
109
86
  end
110
87
  end
data/lib/rage/request.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "ipaddr"
3
4
  require "time"
4
5
 
5
6
  class Rage::Request
@@ -168,16 +169,18 @@ class Rage::Request
168
169
  # request.fresh?(last_modified: Time.utc(2023, 12, 15))
169
170
  # request.fresh?(etag: "123")
170
171
  def fresh?(etag:, last_modified:)
172
+ request_if_none_match = if_none_match
173
+ request_not_modified_since = if_not_modified_since
174
+
171
175
  # Always render response when no freshness information
172
176
  # is provided in the request.
173
- return false unless if_none_match || if_not_modified_since
177
+ return false unless request_if_none_match || request_not_modified_since
174
178
 
175
- etag_matches?(
176
- requested_etags: if_none_match, response_etag: etag
177
- ) && not_modified?(
178
- request_not_modified_since: if_not_modified_since,
179
- response_last_modified: last_modified
180
- )
179
+ if request_if_none_match
180
+ etag_matches?(requested_etags: request_if_none_match, response_etag: etag)
181
+ else
182
+ not_modified?(request_not_modified_since: request_not_modified_since, response_last_modified: last_modified)
183
+ end
181
184
  end
182
185
 
183
186
  # Get the domain part of the request.
@@ -245,7 +248,14 @@ class Rage::Request
245
248
  end
246
249
 
247
250
  def named_host?(host)
248
- !IP_HOST_REGEXP.match?(host)
251
+ host && !ip_host?(host)
252
+ end
253
+
254
+ def ip_host?(host)
255
+ IP_HOST_REGEXP.match?(host) || IPAddr.new(host.delete_prefix("[").delete_suffix("]"))
256
+ true
257
+ rescue IPAddr::InvalidAddressError
258
+ false
249
259
  end
250
260
 
251
261
  def if_none_match
@@ -263,8 +273,10 @@ class Rage::Request
263
273
 
264
274
  return true if requested_etags.empty?
265
275
  return false if response_etag.nil?
276
+ return true if requested_etags.include?("*")
266
277
 
267
- requested_etags.include?(response_etag) || requested_etags.include?("*")
278
+ response_etag = weak_etag(response_etag)
279
+ requested_etags.any? { |requested_etag| weak_etag(requested_etag) == response_etag }
268
280
  end
269
281
 
270
282
  def not_modified?(request_not_modified_since:, response_last_modified:)
@@ -274,6 +286,10 @@ class Rage::Request
274
286
  request_not_modified_since >= response_last_modified
275
287
  end
276
288
 
289
+ private def weak_etag(etag)
290
+ etag.delete_prefix("W/")
291
+ end
292
+
277
293
  # @private
278
294
  class Headers
279
295
  HTTP = "HTTP_"
@@ -69,7 +69,7 @@ class Rage::Router::Constrainer
69
69
  # Optimization: inline the derivation for the common built in constraints
70
70
  if !strategy.custom?
71
71
  if key == :host
72
- lines << " host: env['HTTP_HOST'.freeze]&.sub(/:\\d+\\z/, ''.freeze),"
72
+ lines << " host: env['HTTP_HOST'.freeze]&.sub(/:\\d+\\z/, ''.freeze) || env['SERVER_NAME'.freeze],"
73
73
  else
74
74
  raise ArgumentError, "unknown non-custom strategy for compiling constraint derivation function"
75
75
  end
data/lib/rage/setup.rb CHANGED
@@ -7,6 +7,7 @@ rescue LoadError
7
7
  end
8
8
 
9
9
  # Run application initializers
10
+ Rage::Extension.__initializers.values.flatten.each(&:call) if Rage::Extension.subclasses.any?
10
11
  Dir["#{Rage.root}/config/initializers/**/*.rb"].each { |initializer| load(initializer) }
11
12
 
12
13
  require "rage/ext/setup"
@@ -4,7 +4,7 @@
4
4
  default: &default
5
5
  adapter: mysql2
6
6
  encoding: utf8mb4
7
- pool: <%%= ENV.fetch("DB_MAX_CONNECTIONS") { 5 } %>
7
+ max_connections: <%%= ENV.fetch("DB_MAX_CONNECTIONS") { 5 } %>
8
8
  username: root
9
9
  password:
10
10
  socket: /tmp/mysql.sock
@@ -4,7 +4,7 @@
4
4
  default: &default
5
5
  adapter: postgresql
6
6
  encoding: unicode
7
- pool: <%%= ENV.fetch("DB_MAX_CONNECTIONS") { 5 } %>
7
+ max_connections: <%%= ENV.fetch("DB_MAX_CONNECTIONS") { 5 } %>
8
8
 
9
9
  development:
10
10
  <<: *default
@@ -3,7 +3,7 @@
3
3
  #
4
4
  default: &default
5
5
  adapter: sqlite3
6
- pool: <%%= ENV.fetch("DB_MAX_CONNECTIONS") { 5 } %>
6
+ max_connections: <%%= ENV.fetch("DB_MAX_CONNECTIONS") { 5 } %>
7
7
  timeout: 5000
8
8
 
9
9
  development:
@@ -4,7 +4,7 @@
4
4
  default: &default
5
5
  adapter: trilogy
6
6
  encoding: utf8mb4
7
- pool: <%%= ENV.fetch("DB_MAX_THREADS") { 5 } %>
7
+ max_connections: <%%= ENV.fetch("DB_MAX_THREADS") { 5 } %>
8
8
  username: root
9
9
  password:
10
10
  socket: /tmp/mysql.sock
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.25.1"
4
+ VERSION = "1.26.0"
5
5
  end
data/lib/rage-rb.rb CHANGED
@@ -84,6 +84,12 @@ module Rage
84
84
  # config.log_level = :debug
85
85
  # end
86
86
  def self.configure(&)
87
+ if Rage::Extension.subclasses.any? && !@__extension_config_applied
88
+ Rage::Extension.__configurations.each { |block| config.instance_eval(&block) }
89
+ config.__finalize
90
+ @__extension_config_applied = true
91
+ end
92
+
87
93
  config.instance_eval(&)
88
94
  config.__finalize
89
95
  end
@@ -198,6 +204,7 @@ module Rage
198
204
  autoload :Deferred, "rage/deferred/deferred"
199
205
  autoload :Events, "rage/events/events"
200
206
  autoload :PubSub, "rage/pubsub/pubsub"
207
+ autoload :Daemon, "rage/daemon"
201
208
  end
202
209
 
203
210
  module RageController
@@ -206,3 +213,4 @@ end
206
213
 
207
214
  require_relative "rage/env"
208
215
  require_relative "rage/internal"
216
+ require_relative "rage/extension"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rage-rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.25.1
4
+ version: 1.26.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Roman Samoilov
@@ -43,14 +43,14 @@ dependencies:
43
43
  requirements:
44
44
  - - "~>"
45
45
  - !ruby/object:Gem::Version
46
- version: '5.2'
46
+ version: '5.5'
47
47
  type: :runtime
48
48
  prerelease: false
49
49
  version_requirements: !ruby/object:Gem::Requirement
50
50
  requirements:
51
51
  - - "~>"
52
52
  - !ruby/object:Gem::Version
53
- version: '5.2'
53
+ version: '5.5'
54
54
  - !ruby/object:Gem::Dependency
55
55
  name: zeitwerk
56
56
  requirement: !ruby/object:Gem::Requirement
@@ -158,6 +158,7 @@ files:
158
158
  - lib/rage/controller/api.rb
159
159
  - lib/rage/controller/renderers.rb
160
160
  - lib/rage/cookies.rb
161
+ - lib/rage/daemon.rb
161
162
  - lib/rage/deferred/backends/disk.rb
162
163
  - lib/rage/deferred/backends/nil.rb
163
164
  - lib/rage/deferred/context.rb
@@ -174,6 +175,7 @@ files:
174
175
  - lib/rage/events/subscriber.rb
175
176
  - lib/rage/ext/active_record/connection_pool.rb
176
177
  - lib/rage/ext/setup.rb
178
+ - lib/rage/extension.rb
177
179
  - lib/rage/fiber.rb
178
180
  - lib/rage/fiber_scheduler.rb
179
181
  - lib/rage/hooks.rb