rage-rb 1.17.1 → 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 +4 -4
- data/ARCHITECTURE.md +47 -0
- data/CHANGELOG.md +10 -0
- data/lib/rage/cli.rb +101 -18
- data/lib/rage/code_loader.rb +8 -0
- data/lib/rage/configuration.rb +8 -0
- data/lib/rage/deferred/deferred.rb +4 -0
- data/lib/rage/deferred/task.rb +2 -0
- data/lib/rage/events/events.rb +140 -0
- data/lib/rage/events/subscriber.rb +174 -0
- data/lib/rage/fiber.rb +11 -2
- data/lib/rage/fiber_scheduler.rb +2 -2
- data/lib/rage/internal.rb +39 -0
- data/lib/rage/setup.rb +2 -0
- data/lib/rage/templates/config-environments-production.rb +1 -0
- data/lib/rage/version.rb +1 -1
- data/lib/rage-rb.rb +6 -0
- data/rage.gemspec +1 -0
- metadata +20 -3
- data/OVERVIEW.md +0 -83
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a2a6374b788898c53131e163a84117c929bc377da2859bfffde070d95b765ae9
|
|
4
|
+
data.tar.gz: 375203e192cfd682f672e1a618aa801f975898c961ce81a1bfb3ec136807eadf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
+

|
|
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
|
+

|
|
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
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
|
|
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
|
data/lib/rage/code_loader.rb
CHANGED
|
@@ -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
|
data/lib/rage/configuration.rb
CHANGED
|
@@ -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
|
data/lib/rage/deferred/task.rb
CHANGED
|
@@ -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(
|
|
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(
|
|
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
|
data/lib/rage/fiber_scheduler.rb
CHANGED
|
@@ -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(
|
|
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(
|
|
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
data/lib/rage/version.rb
CHANGED
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
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.
|
|
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-
|
|
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
|
-

|
|
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
|
-

|
|
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.
|