rage-rb 1.17.0 → 1.18.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: 85fae5bbf87d602fb9cbde9e8356a6bfdb9d8e92ef02380f9124047a8d90d268
4
- data.tar.gz: c6ff4ca55836d379609e07dfd9c2025de38aeccc9a29e14803551350f2348652
3
+ metadata.gz: a2a6374b788898c53131e163a84117c929bc377da2859bfffde070d95b765ae9
4
+ data.tar.gz: 375203e192cfd682f672e1a618aa801f975898c961ce81a1bfb3ec136807eadf
5
5
  SHA512:
6
- metadata.gz: 8d81759ffc54e6ede4b079dfed31dda024f274586451951aff72fa02e5cbf1014f31fc5760f07f7817dbf1db78262c88adeaff393020e54cecfd7126c169e0a2
7
- data.tar.gz: fe7a3f9d392ed9343609b0c6c4c80624eb85c2df4be31690247355f11df185af2f9fe8ede04dc01583648f0e7cf75724742f2506a38c804618dbfd53c5fb1dde
6
+ metadata.gz: 4d305b41be71203984c30ff07ce8253befb31b84fbb7c58108983f939dfc5774fb010a4b5d804b2d19e3a4effa9e5b1e755e050ce1453c12a60162d2c48a83fe
7
+ data.tar.gz: 117a17acc84c35254dbffc60999b325d00a2c333c23962f9805c632913d300d887e65250b418665dca79108ada0a74d8862c41f3d1d4c682b7fcce77bbab7c48
data/ARCHITECTURE.md ADDED
@@ -0,0 +1,47 @@
1
+ ### Table of Contents
2
+
3
+ [API Workflow](#api-workflow)<br>
4
+ [Executing Controller Actions](#executing-controller-actions)<br>
5
+ [Cable Workflow](#cable-workflow)<br>
6
+ [OpenAPI Workflow](#openapi-workflow)<br>
7
+ [Design Principles](#design-principles)<br>
8
+
9
+ ### API Workflow
10
+
11
+ The following diagram describes some of Rage's internal components and the way they interact with each other:
12
+
13
+ ![overview](https://github.com/rage-rb/rage/assets/2270393/0d45bbe3-622c-4b17-b8d8-552c567fecb3)
14
+
15
+ ### Executing Controller Actions
16
+
17
+ To maximize runtime performance, Rage pre-compiles controller actions when the application boots. For each action, it resolves the full chain of callbacks and exception handlers, building a single, optimized procedure.
18
+
19
+ When a request comes in, Rage executes this pre-compiled procedure directly, avoiding the overhead of resolving callbacks and exception handlers on every request. All of this happens at boot time to ensure the request-response cycle is as fast as possible.
20
+
21
+ ### Cable Workflow
22
+
23
+ `Rage::Cable` provides a component for handling real-time communication over WebSockets. The workflow involves authenticating connections and subscribing them to channels for bidirectional messaging.
24
+
25
+ The following diagram describes the components of a `Rage::Cable` application:
26
+
27
+ ![cable](https://github.com/user-attachments/assets/86db2091-f93a-44f8-9512-c4701770d09e)
28
+
29
+ ### OpenAPI Workflow
30
+
31
+ `Rage::OpenAPI` generates OpenAPI 3.0 specifications by parsing comments in controller files. This process happens at boot time, building the specification and storing it in memory to be served for API documentation.
32
+
33
+ The following diagram describes the flow of `Rage::OpenAPI`:
34
+
35
+ <img width="800" src="https://github.com/user-attachments/assets/b4a87b1e-9a0f-4432-a3e9-0106ff546f3f" />
36
+
37
+ ### Design Principles
38
+
39
+ * **Lean Happy Path:** We try to execute as many operations as possible during server initialization to minimize workload during request processing. Additionally, new features should be designed to avoid impacting the framework performance for users who do not utilize those features.
40
+
41
+ * **Performance Over Code Style:** We recognize that framework and application code have different requirements. While testability and readability are crucial for application code, framework code prioritizes performance and careful abstraction. This allows for future modifications while maintaining backward compatibility, though readability remains important.
42
+
43
+ * **Rails Compatibility:** A key objective is to provide a familiar experience for Rails developers, with the Controller and Cable APIs being largely compatible. However, Rage is not a reimplementation of Rails. Instead, it provides a familiar foundation and builds upon it with its own unique features.
44
+
45
+ * **Idiomatic Ruby:** We prioritize idiomatic Ruby, avoiding unnecessary abstractions. User-level code is expected to embrace standard Ruby syntax, approaches, and patterns, as this is preferable to a framework-level abstraction that accomplishes the same task.
46
+
47
+ * **Single-Threaded Fiber-Based Approach:** Each request is processed in a separate, isolated execution context (Fiber), pausing whenever it encounters blocking I/O. The single-threaded approach eliminates thread synchronization overhead, leading to enhanced performance and simplified code.
data/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.18.0] - 2025-10-29
4
+
5
+ ### Added
6
+
7
+ - Add `Rage::Events` (#167).
8
+
9
+ ### Fixed
10
+
11
+ - Fix sequential `Fiber.await` calls (#168).
12
+
13
+ ## [1.17.1] - 2025-08-21
14
+
15
+ ### Fixed
16
+
17
+ - Apply backpressure to every `enqueue` call (#166).
18
+
3
19
  ## [1.17.0] - 2025-08-20
4
20
 
5
21
  ### Added
data/lib/rage/cli.rb CHANGED
@@ -12,7 +12,7 @@ module Rage
12
12
  File.expand_path("templates", __dir__)
13
13
  end
14
14
 
15
- desc "migration NAME", "Generate a new migration."
15
+ desc "migration NAME", "Generate a new migration"
16
16
  def migration(name = nil)
17
17
  return help("migration") if name.nil?
18
18
 
@@ -20,7 +20,7 @@ module Rage
20
20
  Rake::Task["db:new_migration"].invoke(name)
21
21
  end
22
22
 
23
- desc "model NAME", "Generate a new model."
23
+ desc "model NAME", "Generate a new model"
24
24
  def model(name = nil)
25
25
  return help("model") if name.nil?
26
26
 
@@ -30,7 +30,7 @@ module Rage
30
30
  template("model-template/model.rb", "app/models/#{name.singularize.underscore}.rb")
31
31
  end
32
32
 
33
- desc "controller NAME", "Generate a new controller."
33
+ desc "controller NAME", "Generate a new controller"
34
34
  def controller(name = nil)
35
35
  return help("controller") if name.nil?
36
36
 
@@ -65,9 +65,9 @@ module Rage
65
65
  true
66
66
  end
67
67
 
68
- desc "new PATH", "Create a new application."
69
- option :database, aliases: "-d", desc: "Preconfigure for selected database.", enum: %w(mysql trilogy postgresql sqlite3)
70
- option :help, aliases: "-h", desc: "Show this message."
68
+ desc "new PATH", "Create a new application"
69
+ option :database, aliases: "-d", desc: "Preconfigure for selected database", enum: %w(mysql trilogy postgresql sqlite3)
70
+ option :help, aliases: "-h", desc: "Show this message"
71
71
  def new(path = nil)
72
72
  return help("new") if options.help? || path.nil?
73
73
 
@@ -75,12 +75,12 @@ module Rage
75
75
  CLINewAppGenerator.start([path, options[:database]])
76
76
  end
77
77
 
78
- desc "s", "Start the app server."
79
- option :port, aliases: "-p", desc: "Runs Rage on the specified port - defaults to 3000."
80
- option :environment, aliases: "-e", desc: "Specifies the environment to run this server under (test/development/production)."
81
- option :binding, aliases: "-b", desc: "Binds Rails to the specified IP - defaults to 'localhost' in development and '0.0.0.0' in other environments."
82
- option :config, aliases: "-c", desc: "Uses a custom rack configuration."
83
- option :help, aliases: "-h", desc: "Show this message."
78
+ desc "s", "Start the app server"
79
+ option :port, aliases: "-p", desc: "Runs Rage on the specified port - defaults to 3000"
80
+ option :environment, aliases: "-e", desc: "Specifies the environment to run this server under (test/development/production)"
81
+ option :binding, aliases: "-b", desc: "Binds Rails to the specified IP - defaults to 'localhost' in development and '0.0.0.0' in other environments"
82
+ option :config, aliases: "-c", desc: "Uses a custom rack configuration"
83
+ option :help, aliases: "-h", desc: "Show this message"
84
84
  def server
85
85
  return help("server") if options.help?
86
86
 
@@ -104,9 +104,9 @@ module Rage
104
104
  ::Iodine.start
105
105
  end
106
106
 
107
- desc "routes", "List all routes."
107
+ desc "routes", "List all routes"
108
108
  option :grep, aliases: "-g", desc: "Filter routes by pattern"
109
- option :help, aliases: "-h", desc: "Show this message."
109
+ option :help, aliases: "-h", desc: "Show this message"
110
110
  def routes
111
111
  return help("routes") if options.help?
112
112
  # the result would be something like this:
@@ -162,8 +162,8 @@ module Rage
162
162
  end
163
163
  end
164
164
 
165
- desc "c", "Start the app console."
166
- option :help, aliases: "-h", desc: "Show this message."
165
+ desc "c", "Start the app console"
166
+ option :help, aliases: "-h", desc: "Show this message"
167
167
  def console
168
168
  return help("console") if options.help?
169
169
 
@@ -185,17 +185,49 @@ module Rage
185
185
  end
186
186
  end
187
187
 
188
+ desc "events [EVENT1, EVENT2]", "List all registered events and their subscribers"
189
+ option :help, aliases: "-h", desc: "Show this message"
190
+ def events(*event_class_names)
191
+ return help("events") if options.help?
192
+
193
+ environment
194
+ Rage::Events.__eager_load_subscribers if Rage.env.development?
195
+
196
+ event_classes = if event_class_names.any?
197
+ event_class_names.flat_map { |name| name.split(",") }.map do |event_class_name|
198
+ @last_event_class_name = event_class_name
199
+ Object.const_get(event_class_name)
200
+ end
201
+ else
202
+ registered_events = Rage::Events.__registered_subscribers.keys
203
+ registered_events.reject do |event_class|
204
+ registered_events.any? { |e| e.ancestors.include?(event_class) && e.ancestors.index(event_class) != 0 }
205
+ end
206
+ end
207
+
208
+ event_classes.each { |event_class| print_event_subscribers_tree(event_class) }
209
+
210
+ rescue NameError
211
+ if @last_event_class_name
212
+ spell_checker = DidYouMean::SpellChecker.new(dictionary: Rage::Events.__registered_subscribers.keys)
213
+ suggestion = DidYouMean.formatter.message_for(spell_checker.correct(@last_event_class_name))
214
+ puts "Could not find the `#{@last_event_class_name}` event. #{suggestion}"
215
+ else
216
+ raise
217
+ end
218
+ end
219
+
188
220
  desc "version", "Return the current version of the framework"
189
221
  def version
190
222
  puts Rage::VERSION
191
223
  end
192
224
 
193
225
  map "generate" => :g
194
- desc "g TYPE", "Generate new code."
226
+ desc "g TYPE", "Generate new code"
195
227
  subcommand "g", CLICodeGenerator
196
228
 
197
229
  map "--tasks" => :tasks
198
- desc "--tasks", "See the list of available tasks."
230
+ desc "--tasks", "See the list of available tasks"
199
231
  def tasks
200
232
  require "io/console"
201
233
 
@@ -275,6 +307,57 @@ module Rage
275
307
  end
276
308
  end
277
309
  end
310
+
311
+ def print_event_subscribers_tree(event_class)
312
+ subscribers = Rage::Events.__get_subscribers(event_class)
313
+
314
+ event_ancestors = event_class.ancestors.take_while { |klass| klass != Struct && klass != Data && klass != Object }
315
+
316
+ # build a tree of all events and their subscribers
317
+ tree = event_ancestors.each_with_object({}) do |ancestor, memo|
318
+ level = event_class.ancestors.count { |klass| klass.ancestors.include?(ancestor) } - 1
319
+ filtered_subscribers = subscribers.select { |subscriber| subscriber.__event_classes.include?(ancestor) }
320
+
321
+ memo[ancestor] = { level:, subscribers: filtered_subscribers }
322
+ end
323
+
324
+ # reject events without subscribers located on the last levels
325
+ i = 0
326
+ tree = tree.reject do |_, node|
327
+ level, subscribers = node[:level], node[:subscribers]
328
+ next_level = tree.values.dig(i + 1, :level)
329
+ i += 1
330
+
331
+ (next_level.nil? || next_level < level) && subscribers.empty?
332
+ end
333
+
334
+ # indentation for each level
335
+ padding = " " * 3
336
+
337
+ # print the tree
338
+ tree.each_with_index do |(event_ancestor, node), i|
339
+ level, subscribers = node[:level], node[:subscribers]
340
+ next_level = tree.values.dig(i + 1, :level)
341
+
342
+ prefix = if i > 0 && next_level != level
343
+ "└─"
344
+ else
345
+ "├─"
346
+ end
347
+
348
+ event_class_line = "#{padding * level}#{prefix} \e[90m#{event_ancestor}\e[0m"
349
+ if level == 0
350
+ puts event_class_line
351
+ else
352
+ puts "|#{event_class_line}"
353
+ end
354
+
355
+ subscribers.each do |subscriber|
356
+ prefix = subscriber == subscribers.last && next_level != level + 1 ? "└─" : "├─"
357
+ puts "│#{padding * (level + 1)}#{prefix} \e[1m#{subscriber}\e[0m"
358
+ end
359
+ end
360
+ end
278
361
  end
279
362
 
280
363
  class CLINewAppGenerator < Thor::Group
@@ -64,6 +64,10 @@ class Rage::CodeLoader
64
64
  @last_watched, @last_update_at = current_watched, current_update_at
65
65
  end
66
66
 
67
+ def load_file(path)
68
+ @loader.load_file(path)
69
+ end
70
+
67
71
  private
68
72
 
69
73
  def configure_components
@@ -83,5 +87,9 @@ class Rage::CodeLoader
83
87
  unless Rage.autoload?(:OpenAPI) # the `OpenAPI` component is loaded
84
88
  Rage::OpenAPI.__reset_data_cache
85
89
  end
90
+
91
+ unless Rage.autoload?(:Events) # the `Events` component is loaded
92
+ Rage::Events.__reset_subscribers
93
+ end
86
94
  end
87
95
  end
@@ -164,7 +164,7 @@ require "erb"
164
164
  # > Enables the backpressure for deferred tasks. The backpressure is used to limit the number of pending tasks in the queue. It accepts a hash with the following options:
165
165
  # >
166
166
  # > - `:high_water_mark` - the maximum number of pending tasks in the queue. Defaults to `1000`.
167
- # > - `:low_water_mark` - the minimum number of pending tasks in the queue before the backpressure is released. Defaults to `800`.
167
+ # > - `:low_water_mark` - the minimum number of pending tasks in the queue before the backpressure is released. Defaults to `high_water_mark * 0.8`.
168
168
  # > - `:timeout` - the timeout for the backpressure in seconds. Defaults to `2`.
169
169
  #
170
170
  # > ```ruby
@@ -443,7 +443,7 @@ class Rage::Configuration
443
443
  return
444
444
  end
445
445
 
446
- if opts.except(:high_water_mark, :low_water_mark, :timeout).any?
446
+ if config.except(:high_water_mark, :low_water_mark, :timeout).any?
447
447
  raise ArgumentError, "unsupported backpressure options; supported keys are `:high_water_mark`, `:low_water_mark`, `:timeout`"
448
448
  end
449
449
 
@@ -502,6 +502,14 @@ class Rage::Configuration
502
502
  class Internal
503
503
  attr_accessor :rails_mode
504
504
 
505
+ def initialized!
506
+ @initialized = true
507
+ end
508
+
509
+ def initialized?
510
+ !!@initialized
511
+ end
512
+
505
513
  def patch_ar_pool?
506
514
  !ENV["RAGE_DISABLE_AR_POOL_PATCH"] && !Rage.env.test?
507
515
  end
@@ -79,6 +79,10 @@ module Rage::Deferred
79
79
 
80
80
  class PushTimeout < StandardError
81
81
  end
82
+
83
+ # @private
84
+ class TaskFailed < StandardError
85
+ end
82
86
  end
83
87
 
84
88
  require_relative "task"
@@ -61,9 +61,7 @@ class Rage::Deferred::Queue
61
61
  private
62
62
 
63
63
  def apply_backpressure
64
- if @backlog_size > @backpressure.high_water_mark && !Fiber[:rage_backpressure_applied]
65
- Fiber[:rage_backpressure_applied] = true
66
-
64
+ if @backlog_size > @backpressure.high_water_mark
67
65
  i, target_backlog_size = 0, @backpressure.low_water_mark
68
66
  while @backlog_size > target_backlog_size && i < @backpressure.timeout_iterations
69
67
  sleep @backpressure.sleep_interval
@@ -63,6 +63,8 @@ module Rage::Deferred::Task
63
63
  __with_optional_log_tag(request_id) do
64
64
  perform(*args, **kwargs)
65
65
  true
66
+ rescue Rage::Deferred::TaskFailed
67
+ false
66
68
  rescue Exception => e
67
69
  Rage.logger.error("Deferred task failed with exception: #{e.class} (#{e.message}):\n#{e.backtrace.join("\n")}")
68
70
  false
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ ##
4
+ # `Rage::Events` provides a lightweight event-driven system for publishing and subscribing to events.
5
+ # Define events as data structures, register subscriber classes, and publish events to notify all relevant subscribers.
6
+ # Subscribers can process events and optionally receive additional context with each event.
7
+ #
8
+ # Define an event:
9
+ # ```ruby
10
+ # UserRegistered = Data.define(:user_id)
11
+ # ```
12
+ #
13
+ # Define a subscriber:
14
+ # ```ruby
15
+ # class SendWelcomeEmail
16
+ # include Rage::Events::Subscriber
17
+ # subscribe_to UserRegistered
18
+ #
19
+ # def call(event)
20
+ # puts "Sending welcome email to user #{event.user_id}"
21
+ # end
22
+ # end
23
+ # ```
24
+ #
25
+ # Publish an event:
26
+ # ```ruby
27
+ # Rage::Events.publish(UserRegistered.new(user_id: 1))
28
+ # ```
29
+ #
30
+ module Rage::Events
31
+ # Publish an event to all subscribers registered for the event's class or its ancestors.
32
+ # Optionally, additional context data can be provided and passed to each subscriber.
33
+ #
34
+ # @param event [Object] the event to publish
35
+ # @param context [Object] additional data to publish along with the event
36
+ # @example Publish an event
37
+ # Rage::Events.publish(MyEvent.new)
38
+ # @example Publish an event with context
39
+ # Rage::Events.publish(MyEvent.new, context: { published_at: Time.now })
40
+ def self.publish(event, context: nil)
41
+ handler = __event_handlers[event.class] || __build_event_handler(event.class)
42
+ handler.call(event, context)
43
+
44
+ nil
45
+ end
46
+
47
+ # @private
48
+ def self.__registered_subscribers
49
+ @__registered_subscribers ||= Hash.new { |hash, key| hash[key] = [] }
50
+ end
51
+
52
+ # @private
53
+ def self.__register_subscriber(event_class, handler_class)
54
+ __registered_subscribers[event_class] << handler_class
55
+ end
56
+
57
+ # @private
58
+ def self.__get_subscribers(event_class)
59
+ event_class.ancestors.take_while { |klass|
60
+ klass != Object && klass != Data
61
+ }.each_with_object([]) { |klass, memo|
62
+ memo.concat(__registered_subscribers[klass]).uniq! if __registered_subscribers.has_key?(klass)
63
+ }
64
+ end
65
+
66
+ # @private
67
+ def self.__event_handlers
68
+ @__event_handlers ||= {}
69
+ end
70
+
71
+ # @private
72
+ def self.__build_event_handler(event_class)
73
+ subscriber_calls = __get_subscribers(event_class).map do |subscriber_class|
74
+ subscriber_class.__register_rescue_handlers
75
+
76
+ arguments = "event"
77
+
78
+ context_type, _ = subscriber_class.instance_method(:call).parameters.find do |param_type, param_name|
79
+ param_name == :context || param_type == :keyrest
80
+ end
81
+
82
+ if context_type
83
+ if context_type == :keyreq
84
+ arguments += ", context: context || {}"
85
+ else
86
+ arguments += ", context:"
87
+ end
88
+ end
89
+
90
+ if subscriber_class.__is_deferred
91
+ "#{subscriber_class}.enqueue(#{arguments})"
92
+ else
93
+ "#{subscriber_class}.new.__call(#{arguments})"
94
+ end
95
+ end
96
+
97
+ if subscriber_calls.empty?
98
+ ->(_, _) {}
99
+ else
100
+ __event_handlers[event_class] = eval <<-RUBY
101
+ ->(event, context) { #{subscriber_calls.join("; ")} }
102
+ RUBY
103
+ end
104
+ end
105
+
106
+ # @private
107
+ def self.__reset_subscribers
108
+ __registered_subscribers.clear
109
+ __event_handlers.clear
110
+
111
+ Rage::Events.__eager_load_subscribers
112
+ end
113
+
114
+ # @private
115
+ def self.__eager_load_subscribers
116
+ subscribers = Dir["#{Rage.root}/app/**/*.rb"].select do |path|
117
+ File.foreach(path).any? do |line|
118
+ line.include?("include Rage::Events::Subscriber") || line.include?("subscribe_to")
119
+ end
120
+ end
121
+
122
+ subscribers.each do |path|
123
+ Rage.code_loader.load_file(path)
124
+ end
125
+
126
+ rescue => e
127
+ puts "ERROR: Failed to load an event subscriber: #{e.class} (#{e.message})."
128
+ puts e.backtrace.join("\n")
129
+ end
130
+ end
131
+
132
+ require_relative "subscriber"
133
+
134
+ if Rage.env.development?
135
+ if Rage.config.internal.initialized?
136
+ Rage::Events.__eager_load_subscribers
137
+ else
138
+ Rage.config.after_initialize { Rage::Events.__eager_load_subscribers }
139
+ end
140
+ end
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ ##
4
+ # Include this module in a class to make it an event subscriber.
5
+ #
6
+ # Example:
7
+ #
8
+ # ```ruby
9
+ # # Define an event class
10
+ # MyEvent = Data.define
11
+ #
12
+ # # Define the subscriber class
13
+ # class MySubscriber
14
+ # include Rage::Events::Subscriber
15
+ # subscribe_to MyEvent
16
+ #
17
+ # def call(event)
18
+ # puts "Received event: #{event.inspect}"
19
+ # end
20
+ # end
21
+ #
22
+ # # Publish an event
23
+ # Rage::Events.publish(MyEvent.new)
24
+ # ```
25
+ #
26
+ # When an event matching the specified class is published, the `call` method will be invoked with the event instance.
27
+ #
28
+ # You can also subscribe to multiple event classes:
29
+ #
30
+ # ```ruby
31
+ # class MySubscriber
32
+ # include Rage::Events::Subscriber
33
+ # subscribe_to EventA, EventB
34
+ #
35
+ # def call(event)
36
+ # puts "Received event: #{event.inspect}"
37
+ # end
38
+ # end
39
+ # ```
40
+ #
41
+ # Subscribers are executed synchronously by default. You can make a subscriber asynchronous by passing the `deferred` option:
42
+ #
43
+ # ```ruby
44
+ # class MySubscriber
45
+ # include Rage::Events::Subscriber
46
+ # subscribe_to MyEvent, deferred: true
47
+ #
48
+ # def call(event)
49
+ # puts "Received event in background: #{event.inspect}"
50
+ # end
51
+ # end
52
+ # ```
53
+ #
54
+ # Such subscriber will be executed in the background using Rage's deferred task system.
55
+ #
56
+ # You can also define custom error handling for exceptions raised during event processing using `rescue_from`:
57
+ #
58
+ # ```ruby
59
+ # class MySubscriber
60
+ # include Rage::Events::Subscriber
61
+ # subscribe_to MyEvent
62
+ #
63
+ # rescue_from StandardError do |exception|
64
+ # puts "An error occurred: #{exception.message}"
65
+ # end
66
+ # end
67
+ # ```
68
+ #
69
+ # @see ClassMethods
70
+ #
71
+ module Rage::Events::Subscriber
72
+ def self.included(handler_class)
73
+ handler_class.extend ClassMethods
74
+ end
75
+
76
+ # @private
77
+ def call(_)
78
+ end
79
+
80
+ # @private
81
+ def __call(event, context: nil)
82
+ Rage.logger.with_context(self.class.__log_context) do
83
+ context.nil? ? call(event) : call(event, context: context.freeze)
84
+ rescue Exception => _e
85
+ e = self.class.__rescue_handlers ? __run_rescue_handlers(_e) : _e
86
+
87
+ if e
88
+ Rage.logger.error("Subscriber failed with exception: #{e.class} (#{e.message}):\n#{e.backtrace.join("\n")}")
89
+ raise Rage::Deferred::TaskFailed if self.class.__is_deferred
90
+ end
91
+ end
92
+ end
93
+
94
+ module ClassMethods
95
+ # @private
96
+ attr_accessor :__event_classes, :__is_deferred, :__log_context, :__rescue_handlers
97
+
98
+ # Subscribe the class to one or more events.
99
+ #
100
+ # @param event_classes [Class, Array<Class>] one or more event classes to subscribe to
101
+ # @param deferred [Boolean] whether to process events asynchronously
102
+ def subscribe_to(*event_classes, deferred: false)
103
+ @__event_classes = (@__event_classes || []) | event_classes
104
+ @__is_deferred = !!deferred
105
+ @__log_context = { subscriber: name }.freeze
106
+
107
+ @__event_classes.each do |event_class|
108
+ Rage::Events.__register_subscriber(event_class, self)
109
+ end
110
+
111
+ if @__is_deferred
112
+ include Rage::Deferred::Task
113
+ alias_method :perform, :__call
114
+ end
115
+ end
116
+
117
+ # Define exception handlers for the subscriber.
118
+ #
119
+ # @param klasses [Class, Array<Class>] one or more exception classes to handle
120
+ # @param with [Symbol, String] the method name to call when an exception is raised
121
+ # @yield [exception] optional block to handle the exception
122
+ # @note If you do not re-raise exceptions in deferred subscribers, the subscriber will be marked as successful and Rage will not attempt to retry it.
123
+ def rescue_from(*klasses, with: nil, &block)
124
+ unless with
125
+ if block_given?
126
+ with = Rage::Internal.define_dynamic_method(self, block)
127
+ else
128
+ raise ArgumentError, "No handler provided. Pass the `with` keyword argument or provide a block."
129
+ end
130
+ end
131
+
132
+ if @__rescue_handlers.nil?
133
+ @__rescue_handlers = []
134
+ elsif @__rescue_handlers.frozen?
135
+ @__rescue_handlers = @__rescue_handlers.dup
136
+ end
137
+
138
+ @__rescue_handlers.unshift([klasses, with])
139
+ end
140
+
141
+ # @private
142
+ def inherited(klass)
143
+ klass.__rescue_handlers = @__rescue_handlers.freeze
144
+ klass.subscribe_to(*@__event_classes, deferred: @__is_deferred) if @__event_classes
145
+ end
146
+
147
+ # @private
148
+ def __register_rescue_handlers
149
+ return if method_defined?(:__run_rescue_handlers, false) || @__rescue_handlers.nil?
150
+
151
+ matcher_calls = @__rescue_handlers.map do |klasses, handler|
152
+ handler_call = instance_method(handler).arity == 0 ? handler : "#{handler}(exception)"
153
+
154
+ <<~RUBY
155
+ when #{klasses.join(", ")}
156
+ #{handler_call}
157
+ nil
158
+ RUBY
159
+ end
160
+
161
+ class_eval <<~RUBY, __FILE__, __LINE__ + 1
162
+ def __run_rescue_handlers(exception)
163
+ case exception
164
+ #{matcher_calls.join("\n")}
165
+ else
166
+ exception
167
+ end
168
+ rescue Exception => e
169
+ e
170
+ end
171
+ RUBY
172
+ end
173
+ end
174
+ end
data/lib/rage/fiber.rb CHANGED
@@ -118,6 +118,14 @@ class Fiber
118
118
  "block:#{object_id}:#{@__block_channel_i}"
119
119
  end
120
120
 
121
+ # @private
122
+ def __await_channel(force = false)
123
+ @__fiber_channel_i ||= 0
124
+ @__fiber_channel_i += 1 if force
125
+
126
+ "await:#{object_id}:#{@__fiber_channel_i}"
127
+ end
128
+
121
129
  # @private
122
130
  attr_accessor :__awaited_fileno
123
131
 
@@ -148,6 +156,7 @@ class Fiber
148
156
  # @note This method should only be used when multiple fibers have to be processed in parallel. There's no need to use `Fiber.await` for single IO calls.
149
157
  def self.await(fibers)
150
158
  f, fibers = Fiber.current, Array(fibers)
159
+ await_channel = f.__await_channel(true)
151
160
 
152
161
  # check which fibers are alive (i.e. have yielded) and which have errored out
153
162
  i, err, num_wait_for = 0, nil, 0
@@ -169,7 +178,7 @@ class Fiber
169
178
  end
170
179
 
171
180
  # wait on async fibers; resume right away if one of the fibers errors out
172
- Iodine.subscribe("await:#{f.object_id}") do |_, err|
181
+ Iodine.subscribe(await_channel) do |_, err|
173
182
  if err == AWAIT_ERROR_MESSAGE
174
183
  f.resume
175
184
  else
@@ -179,7 +188,7 @@ class Fiber
179
188
  end
180
189
 
181
190
  Fiber.defer(-1)
182
- Iodine.defer { Iodine.unsubscribe("await:#{f.object_id}") }
191
+ Iodine.defer { Iodine.unsubscribe(await_channel) }
183
192
 
184
193
  # if num_wait_for is not 0 means we exited prematurely because of an error
185
194
  if num_wait_for > 0
@@ -130,10 +130,10 @@ class Rage::FiberScheduler
130
130
  Thread.current[:rage_logger] = logger
131
131
  Fiber.current.__set_result(block.call)
132
132
  # send a message for `Fiber.await` to work
133
- Iodine.publish("await:#{parent.object_id}", "", Iodine::PubSub::PROCESS) if parent.alive?
133
+ Iodine.publish(parent.__await_channel, "", Iodine::PubSub::PROCESS) if parent.alive?
134
134
  rescue Exception => e
135
135
  Fiber.current.__set_err(e)
136
- Iodine.publish("await:#{parent.object_id}", Fiber::AWAIT_ERROR_MESSAGE, Iodine::PubSub::PROCESS) if parent.alive?
136
+ Iodine.publish(parent.__await_channel, Fiber::AWAIT_ERROR_MESSAGE, Iodine::PubSub::PROCESS) if parent.alive?
137
137
  end
138
138
  end
139
139
 
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ # @private
4
+ class Rage::Internal
5
+ class << self
6
+ # Define a method based on a block.
7
+ # @param klass [Class] the class to define the method in
8
+ # @param block [Proc] the implementation of the new method
9
+ # @return [Symbol] the name of the newly defined method
10
+ def define_dynamic_method(klass, block)
11
+ name = dynamic_name_seed.next.join
12
+ klass.define_method("__rage_dynamic_#{name}", block)
13
+ end
14
+
15
+ # Define a method that will call a specified method if a condition is `true` or yield if `false`.
16
+ # @param klass [Class] the class to define the method in
17
+ # @param method_name [Symbol] the method to call if the condition is `true`
18
+ # @return [Symbol] the name of the newly defined method
19
+ def define_maybe_yield(klass, method_name)
20
+ name = dynamic_name_seed.next.join
21
+
22
+ klass.class_eval <<~RUBY, __FILE__, __LINE__ + 1
23
+ def __rage_dynamic_#{name}(condition)
24
+ if condition
25
+ #{method_name} { yield }
26
+ else
27
+ yield
28
+ end
29
+ end
30
+ RUBY
31
+ end
32
+
33
+ private
34
+
35
+ def dynamic_name_seed
36
+ @dynamic_name_seed ||= ("a".."j").to_a.permutation
37
+ end
38
+ end
39
+ end
data/lib/rage/setup.rb CHANGED
@@ -18,3 +18,5 @@ Rage.code_loader.setup
18
18
  Rage.config.run_after_initialize!
19
19
 
20
20
  require_relative "#{Rage.root}/config/routes"
21
+
22
+ Rage.config.internal.initialized!
@@ -8,4 +8,5 @@ Rage.configure do
8
8
  # Specify the logger
9
9
  config.logger = Rage::Logger.new(STDOUT)
10
10
  config.log_level = Logger::INFO
11
+ config.log_formatter = Rage::JSONFormatter.new
11
12
  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.17.0"
4
+ VERSION = "1.18.0"
5
5
  end
data/lib/rage-rb.rb CHANGED
@@ -26,6 +26,10 @@ module Rage
26
26
  Rage::Deferred
27
27
  end
28
28
 
29
+ def self.events
30
+ Rage::Events
31
+ end
32
+
29
33
  def self.routes
30
34
  Rage::Router::DSL.new(__router)
31
35
  end
@@ -135,9 +139,11 @@ module Rage
135
139
  autoload :Cable, "rage/cable/cable"
136
140
  autoload :OpenAPI, "rage/openapi/openapi"
137
141
  autoload :Deferred, "rage/deferred/deferred"
142
+ autoload :Events, "rage/events/events"
138
143
  end
139
144
 
140
145
  module RageController
141
146
  end
142
147
 
143
148
  require_relative "rage/env"
149
+ require_relative "rage/internal"
data/rage.gemspec CHANGED
@@ -33,4 +33,5 @@ Gem::Specification.new do |spec|
33
33
  spec.add_dependency "zeitwerk", "~> 2.6"
34
34
  spec.add_dependency "rack-test", "~> 2.1"
35
35
  spec.add_dependency "rake", ">= 12.0"
36
+ spec.add_dependency "logger"
36
37
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rage-rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.17.0
4
+ version: 1.18.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Roman Samoilov
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2025-08-20 00:00:00.000000000 Z
10
+ date: 2025-10-29 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: thor
@@ -93,6 +93,20 @@ dependencies:
93
93
  - - ">="
94
94
  - !ruby/object:Gem::Version
95
95
  version: '12.0'
96
+ - !ruby/object:Gem::Dependency
97
+ name: logger
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - ">="
101
+ - !ruby/object:Gem::Version
102
+ version: '0'
103
+ type: :runtime
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ version: '0'
96
110
  email:
97
111
  - rsamoi@icloud.com
98
112
  executables:
@@ -102,11 +116,11 @@ extra_rdoc_files: []
102
116
  files:
103
117
  - ".rspec"
104
118
  - ".yardopts"
119
+ - ARCHITECTURE.md
105
120
  - CHANGELOG.md
106
121
  - CODE_OF_CONDUCT.md
107
122
  - Gemfile
108
123
  - LICENSE.txt
109
- - OVERVIEW.md
110
124
  - README.md
111
125
  - Rakefile
112
126
  - exe/rage
@@ -137,11 +151,14 @@ files:
137
151
  - lib/rage/deferred/task.rb
138
152
  - lib/rage/env.rb
139
153
  - lib/rage/errors.rb
154
+ - lib/rage/events/events.rb
155
+ - lib/rage/events/subscriber.rb
140
156
  - lib/rage/ext/active_record/connection_pool.rb
141
157
  - lib/rage/ext/setup.rb
142
158
  - lib/rage/fiber.rb
143
159
  - lib/rage/fiber_scheduler.rb
144
160
  - lib/rage/hooks.rb
161
+ - lib/rage/internal.rb
145
162
  - lib/rage/logger/json_formatter.rb
146
163
  - lib/rage/logger/logger.rb
147
164
  - lib/rage/logger/text_formatter.rb
data/OVERVIEW.md DELETED
@@ -1,83 +0,0 @@
1
- ### Table of Contents
2
-
3
- [API Workflow](#api-workflow)<br>
4
- [Executing Controller Actions](#executing-controller-actions)<br>
5
- [Cable Workflow](#cable-workflow)<br>
6
- [OpenAPI Workflow](#openapi-workflow)<br>
7
- [Design Principles](#design-principles)<br>
8
-
9
- ### API Workflow
10
-
11
- The following diagram describes some of Rage's internal components and the way they interact with each other:
12
-
13
- ![overview](https://github.com/rage-rb/rage/assets/2270393/0d45bbe3-622c-4b17-b8d8-552c567fecb3)
14
-
15
- ### Executing Controller Actions
16
-
17
- When `Rage::Router::DSL` parses the `config/routes.rb` file and calls the `Rage::Router::Backend` class, it registers actions and stores handler procs.
18
-
19
- Consider we have the following controller:
20
-
21
- ```ruby
22
- class UsersController < RageController::API
23
- before_action :find_user
24
- rescue_from ActiveRecord::RecordNotFound, with: :render_not_found
25
-
26
- def show
27
- render json: @user
28
- end
29
-
30
- private
31
-
32
- def find_user
33
- @user = User.find(params[:id])
34
- end
35
-
36
- def render_not_found(_)
37
- render status: :not_found
38
- end
39
- end
40
- ```
41
-
42
- Before processing requests to `UsersController#show`, Rage has to [register](https://github.com/rage-rb/rage/blob/master/lib/rage/controller/api.rb#L11) the show action. Registering means defining a new method that will look like this:
43
-
44
- ```ruby
45
- class UsersController
46
- def __run_show
47
- find_user
48
- show
49
- rescue ActiveRecord::RecordNotFound => e
50
- render_not_found(e)
51
- end
52
- end
53
- ```
54
-
55
- After that, Rage will create and store a handler proc that will look exactly like this:
56
-
57
- ```ruby
58
- ->(env, params) { UsersController.new(env, params).__run_show }
59
- ```
60
-
61
- All of this happens at boot time. Once the request comes in at runtime, Rage will only need to retrieve the handler proc defined earlier and call it.
62
-
63
- ### Cable Workflow
64
-
65
- The following diagram describes the components of a `Rage::Cable` application:
66
-
67
- ![cable](https://github.com/user-attachments/assets/86db2091-f93a-44f8-9512-c4701770d09e)
68
-
69
- ### OpenAPI Workflow
70
-
71
- The following diagram describes the flow of `Rage::OpenAPI`:
72
-
73
- <img width="800" src="https://github.com/user-attachments/assets/b4a87b1e-9a0f-4432-a3e9-0106ff546f3f" />
74
-
75
- ### Design Principles
76
-
77
- * **Lean Happy Path:** we try to execute as many operations as possible during server initialization to minimize workload during request processing. Additionally, new features should be designed to avoid impacting the framework performance for users who do not utilize those features.
78
-
79
- * **Performance Over Code Style:** we recognize the distinct requirements of framework and client code. Testability, readability, and maintainability are crucial for client code used in application development. Conversely, library code addresses different tasks and should be designed with different objectives. In library code, performance and abstraction to enable future modifications while maintaining backward compatibility take precedence over typical client code concerns, though testability and readability remain important.
80
-
81
- * **Rails Compatibility:** Rails compatibility is a key objective to ensure a seamless transition for developers. While it may not be feasible to replicate every method implemented in Rails, the framework should function in a familiar and expected manner.
82
-
83
- * **Single-Threaded Fiber-Based Approach:** each request is processed in a separate, isolated execution context (Fiber), pausing whenever it encounters blocking I/O. This single-threaded approach eliminates thread synchronization overhead, leading to enhanced performance and simplified code.