rage-rb 1.22.1 → 1.23.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/CHANGELOG.md +20 -0
- data/README.md +1 -1
- data/lib/rage/all.rb +1 -0
- data/lib/rage/configuration.rb +107 -0
- data/lib/rage/controller/api.rb +15 -5
- data/lib/rage/controller/renderers.rb +47 -0
- data/lib/rage/deferred/backends/disk.rb +19 -3
- data/lib/rage/deferred/metadata.rb +1 -1
- data/lib/rage/deferred/queue.rb +5 -4
- data/lib/rage/deferred/task.rb +72 -5
- data/lib/rage/errors.rb +3 -0
- data/lib/rage/internal.rb +36 -0
- data/lib/rage/logger/logger.rb +1 -1
- data/lib/rage/openapi/converter.rb +43 -2
- data/lib/rage/openapi/openapi.rb +11 -0
- data/lib/rage/openapi/parsers/ext/alba.rb +5 -4
- data/lib/rage/pubsub/adapters/redis.rb +147 -0
- data/lib/rage/pubsub/pubsub.rb +25 -0
- data/lib/rage/rails.rb +16 -0
- data/lib/rage/router/README.md +1 -1
- data/lib/rage/router/dsl.rb +67 -10
- data/lib/rage/sse/application.rb +30 -2
- data/lib/rage/sse/sse.rb +96 -0
- data/lib/rage/sse/stream.rb +78 -0
- data/lib/rage/telemetry/spans/broadcast_sse_stream.rb +50 -0
- data/lib/rage/telemetry/spans/process_sse_stream.rb +1 -0
- data/lib/rage/telemetry/telemetry.rb +2 -1
- data/lib/rage/uploaded_file.rb +3 -7
- data/lib/rage/version.rb +1 -1
- data/lib/rage-rb.rb +2 -1
- metadata +7 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bbc12eab5d13d5570107f5c7bf6d0b68f6f652eccc1eaafefdb47ef92ed3b264
|
|
4
|
+
data.tar.gz: 7adca2c0e96d38ca69c32f4fc12b03984b6aa46578d48d084645e11891915e99
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cb010618b1afebee5baa5fee44963d8cdf4f1543769b79ee81d26e9a0815c3ea59d6b0cf4dad2f53adabbe8d26ae0688876d2a0f690e1497173469446ba4a9e8
|
|
7
|
+
data.tar.gz: ee292c1c8b93fed32dbe015e6de898df1f0bb63ede69deb2b1eb41cddee1e339ade19ed8981682697721b02c3923bb8782358616c5d7c264cc0d465887b6e79b
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [1.23.0] - 2026-04-15
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- [SSE] Ensure connection is closed for single-value SSE streams by [@jsxs0](https://github.com/jsxs0) (#264).
|
|
7
|
+
- Ensure task ID seed is always greater than timestamps in existing WAL files by [@Abishekcs](https://github.com/Abishekcs) (#255)
|
|
8
|
+
- Correctly load routes in Rails apps (#249).
|
|
9
|
+
- [SSE] Ensure connection is closed when raw SSE stream raises by [@jsxs0](https://github.com/jsxs0) (#248).
|
|
10
|
+
- [OpenAPI] Alba parser: silent fallback for unresolvable association resources by [@pratyush07-hub](https://github.com/pratyush07-hub) (#258).
|
|
11
|
+
- Fix `Rage::UploadedFile#close` (#262).
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
- [SSE] Add tests for log context propagation across fiber boundaries by [@jsxs0](https://github.com/jsxs0) (#267).
|
|
15
|
+
- Add singular `resource` routing with plural controller mapping and document the helper by [@anuj-pal27](https://github.com/anuj-pal27) (#247).
|
|
16
|
+
- [SSE] Add support for unbounded streams (#266).
|
|
17
|
+
- [OpenAPI] Support OpenAPI generation for file parameters by [@Digvijay-x1](https://github.com/Digvijay-x1) (#229).
|
|
18
|
+
- [Deferred] Add configurable retry options by [@Digvijay-x1](https://github.com/Digvijay-x1) (#225).
|
|
19
|
+
- [SSE] Add unit tests for `SSE::ConnectionProxy` by [@jsxs0](https://github.com/jsxs0) (#245).
|
|
20
|
+
- Custom renderer support by [@anuj-pal27](https://github.com/anuj-pal27) (#244).
|
|
21
|
+
- [SSE] Add graceful shutdown support for SSE streams by [@tmchow](https://github.com/tmchow) (#261).
|
|
22
|
+
|
|
3
23
|
## [1.22.1] - 2026-04-01
|
|
4
24
|
|
|
5
25
|
### Fixed
|
data/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
[](https://badge.fury.io/rb/rage-rb)
|
|
6
6
|

|
|
7
|
-

|
|
8
8
|
|
|
9
9
|
**Rage** is an API-first Ruby framework with a modern, fiber-based runtime that enables transparent, non-blocking concurrency while preserving familiar developer ergonomics. It focuses on **capability and operational simplicity**, letting teams build production-grade systems in a single, coherent runtime.
|
|
10
10
|
|
data/lib/rage/all.rb
CHANGED
data/lib/rage/configuration.rb
CHANGED
|
@@ -139,6 +139,52 @@ class Rage::Configuration
|
|
|
139
139
|
def after_initialize(&block)
|
|
140
140
|
push_hook(block, :after_initialize)
|
|
141
141
|
end
|
|
142
|
+
|
|
143
|
+
# Register a custom renderer that generates overloads `render` on all controllers.
|
|
144
|
+
# The block receives the object passed to `render` together with any additional keyword arguments.
|
|
145
|
+
# The code inside the block is executed in the context of the controller instance, so you can access all usual controller methods in it.
|
|
146
|
+
# The return value of the block is used as the response body.
|
|
147
|
+
#
|
|
148
|
+
# @param name [Symbol, String] the name of the renderer
|
|
149
|
+
# @param block [Proc] the rendering logic. The block is executed in the controller's context and its return value becomes the response body
|
|
150
|
+
# @raise [ArgumentError] if no block is given or if a renderer with the same name is already registered
|
|
151
|
+
#
|
|
152
|
+
# @example Register an ERB renderer
|
|
153
|
+
# Rage.configure do
|
|
154
|
+
# config.renderer(:erb) do |path, trim_mode: nil|
|
|
155
|
+
# headers["content-type"] = "text/html"
|
|
156
|
+
# template = File.read("app/views/#{path}.html.erb")
|
|
157
|
+
#
|
|
158
|
+
# ERB.new(template, trim_mode:).result(binding)
|
|
159
|
+
# end
|
|
160
|
+
# end
|
|
161
|
+
# @example Use in a controller
|
|
162
|
+
# class ReportsController < RageController::API
|
|
163
|
+
# def index
|
|
164
|
+
# render erb: "reports/index"
|
|
165
|
+
# end
|
|
166
|
+
# end
|
|
167
|
+
# @example Pass arguments
|
|
168
|
+
# class ReportsController < RageController::API
|
|
169
|
+
# def index
|
|
170
|
+
# render erb: "reports/index", trim_mode: "%<>"
|
|
171
|
+
# end
|
|
172
|
+
# end
|
|
173
|
+
# @example Set response status
|
|
174
|
+
# class ReportsController < RageController::API
|
|
175
|
+
# def index
|
|
176
|
+
# render erb: "reports/index", status: 202
|
|
177
|
+
# end
|
|
178
|
+
# end
|
|
179
|
+
def renderer(name, &block)
|
|
180
|
+
@renderers ||= {}
|
|
181
|
+
raise ArgumentError, "renderer requires a block" unless block_given?
|
|
182
|
+
name = name.to_sym
|
|
183
|
+
if @renderers.key?(name)
|
|
184
|
+
raise ArgumentError, "a renderer named :#{name} is already registered"
|
|
185
|
+
end
|
|
186
|
+
@renderers[name] = RendererEntry.new(block)
|
|
187
|
+
end
|
|
142
188
|
# @!endgroup
|
|
143
189
|
|
|
144
190
|
# @!group Middleware Configuration
|
|
@@ -219,6 +265,11 @@ class Rage::Configuration
|
|
|
219
265
|
end
|
|
220
266
|
# @!endgroup
|
|
221
267
|
|
|
268
|
+
# @private
|
|
269
|
+
def pubsub
|
|
270
|
+
@pubsub ||= PubSub.new
|
|
271
|
+
end
|
|
272
|
+
|
|
222
273
|
# @private
|
|
223
274
|
def internal
|
|
224
275
|
@internal ||= Internal.new
|
|
@@ -937,6 +988,37 @@ class Rage::Configuration
|
|
|
937
988
|
attr_accessor :key
|
|
938
989
|
end
|
|
939
990
|
|
|
991
|
+
# @private
|
|
992
|
+
class PubSub
|
|
993
|
+
attr_reader :adapter
|
|
994
|
+
|
|
995
|
+
def initialize
|
|
996
|
+
@adapter = if config.any?
|
|
997
|
+
case config[:adapter]
|
|
998
|
+
when "redis"
|
|
999
|
+
Rage::PubSub::Adapters::Redis.new(adapter_config)
|
|
1000
|
+
end
|
|
1001
|
+
end
|
|
1002
|
+
end
|
|
1003
|
+
|
|
1004
|
+
def config
|
|
1005
|
+
@config ||= begin
|
|
1006
|
+
config_file = Rage.root.join("config/pubsub.yml")
|
|
1007
|
+
|
|
1008
|
+
config = if config_file.exist?
|
|
1009
|
+
yaml = ERB.new(config_file.read).result
|
|
1010
|
+
YAML.safe_load(yaml, aliases: true, symbolize_names: true)&.dig(Rage.env.to_sym)
|
|
1011
|
+
end
|
|
1012
|
+
|
|
1013
|
+
config || {}
|
|
1014
|
+
end
|
|
1015
|
+
end
|
|
1016
|
+
|
|
1017
|
+
def adapter_config
|
|
1018
|
+
config.except(:adapter)
|
|
1019
|
+
end
|
|
1020
|
+
end
|
|
1021
|
+
|
|
940
1022
|
# @private
|
|
941
1023
|
class Internal
|
|
942
1024
|
attr_accessor :rails_mode
|
|
@@ -999,7 +1081,32 @@ class Rage::Configuration
|
|
|
999
1081
|
end
|
|
1000
1082
|
|
|
1001
1083
|
Rage::Telemetry.__setup(@telemetry.handlers_map) if @telemetry
|
|
1084
|
+
|
|
1085
|
+
__define_custom_renderers if @renderers
|
|
1086
|
+
end
|
|
1087
|
+
|
|
1088
|
+
# @private
|
|
1089
|
+
class RendererEntry
|
|
1090
|
+
attr_reader :block
|
|
1091
|
+
|
|
1092
|
+
def initialize(block)
|
|
1093
|
+
@block = block
|
|
1094
|
+
@applied = false
|
|
1095
|
+
end
|
|
1096
|
+
|
|
1097
|
+
def applied? = @applied
|
|
1098
|
+
def applied! = (@applied = true)
|
|
1099
|
+
end
|
|
1100
|
+
private_constant :RendererEntry
|
|
1101
|
+
|
|
1102
|
+
def __define_custom_renderers
|
|
1103
|
+
@renderers.each do |name, entry|
|
|
1104
|
+
next if entry.applied?
|
|
1105
|
+
RageController::API.__register_renderer(name, entry.block)
|
|
1106
|
+
entry.applied!
|
|
1107
|
+
end
|
|
1002
1108
|
end
|
|
1109
|
+
private :__define_custom_renderers
|
|
1003
1110
|
end
|
|
1004
1111
|
|
|
1005
1112
|
# @!parse [ruby]
|
data/lib/rage/controller/api.rb
CHANGED
|
@@ -211,6 +211,12 @@ class RageController::API
|
|
|
211
211
|
RUBY
|
|
212
212
|
end
|
|
213
213
|
|
|
214
|
+
# @private
|
|
215
|
+
def __register_renderer(name, block)
|
|
216
|
+
prepend(RageController::Renderers) unless ancestors.include?(RageController::Renderers)
|
|
217
|
+
RageController::Renderers.__register(name, block)
|
|
218
|
+
end
|
|
219
|
+
|
|
214
220
|
############
|
|
215
221
|
#
|
|
216
222
|
# PUBLIC API
|
|
@@ -445,11 +451,14 @@ class RageController::API
|
|
|
445
451
|
end
|
|
446
452
|
end # class << self
|
|
447
453
|
|
|
454
|
+
DEFAULT_CONTENT_TYPE = "application/json; charset=utf-8"
|
|
455
|
+
private_constant :DEFAULT_CONTENT_TYPE
|
|
456
|
+
|
|
448
457
|
# @private
|
|
449
458
|
def initialize(env, params)
|
|
450
459
|
@__env = env
|
|
451
460
|
@__params = params
|
|
452
|
-
@__status, @__headers, @__body = 204, { "content-type" =>
|
|
461
|
+
@__status, @__headers, @__body = 204, { "content-type" => DEFAULT_CONTENT_TYPE }, []
|
|
453
462
|
@__rendered = false
|
|
454
463
|
end
|
|
455
464
|
|
|
@@ -480,10 +489,10 @@ class RageController::API
|
|
|
480
489
|
@session ||= Rage::Session.new(cookies)
|
|
481
490
|
end
|
|
482
491
|
|
|
483
|
-
# Send a response to the client.
|
|
492
|
+
# Send a response to the client. Keywords corresponding to custom renderers (see {Rage::Configuration#renderer}) will be delegated automatically.
|
|
484
493
|
#
|
|
485
|
-
# @param json [String,
|
|
486
|
-
# @param plain [
|
|
494
|
+
# @param json [String, #to_json] send a json response to the client; objects will be serialized automatically
|
|
495
|
+
# @param plain [#to_s] send a text response to the client
|
|
487
496
|
# @param sse [#each, Proc, #to_json] send an SSE response to the client
|
|
488
497
|
# @param status [Integer, Symbol] set a response status
|
|
489
498
|
# @example Render a JSON object
|
|
@@ -508,7 +517,8 @@ class RageController::API
|
|
|
508
517
|
@__body << if json
|
|
509
518
|
json.is_a?(String) ? json : json.to_json
|
|
510
519
|
else
|
|
511
|
-
@__headers["content-type"]
|
|
520
|
+
ct = @__headers["content-type"]
|
|
521
|
+
@__headers["content-type"] = "text/plain; charset=utf-8" if ct.nil? || ct == DEFAULT_CONTENT_TYPE
|
|
512
522
|
plain.to_s
|
|
513
523
|
end
|
|
514
524
|
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
##
|
|
4
|
+
# This module overloads the `render` method on {RageController::API RageController::API} to enable the usage of custom renderers defined using {Rage::Configuration#renderer}.
|
|
5
|
+
#
|
|
6
|
+
module RageController::Renderers
|
|
7
|
+
# @private
|
|
8
|
+
def self.prepended(_)
|
|
9
|
+
@__renderers = {}
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# @private
|
|
13
|
+
# rubocop:disable Layout/IndentationWidth, Layout/EndAlignment, Layout/HeredocIndentation
|
|
14
|
+
def self.__register(name, block)
|
|
15
|
+
@__renderers[name] = Rage::Internal.define_dynamic_method(self, block)
|
|
16
|
+
|
|
17
|
+
render_args = @__renderers.keys.map { |key| "#{key}: nil" }.join(", ")
|
|
18
|
+
|
|
19
|
+
class_eval <<~RUBY, __FILE__, __LINE__ + 1
|
|
20
|
+
def render(#{render_args}, status: nil, **)
|
|
21
|
+
raise "Render was called multiple times in this action." if @__rendered
|
|
22
|
+
|
|
23
|
+
active_renderers = []
|
|
24
|
+
#{@__renderers.keys.map { |key| "active_renderers << :#{key} if #{key}" }.join("\n")}
|
|
25
|
+
|
|
26
|
+
return super(status:, **) if active_renderers.empty?
|
|
27
|
+
|
|
28
|
+
if active_renderers.size > 1
|
|
29
|
+
raise Rage::Errors::AmbiguousRenderError, "Only one renderer can be used per 'render' call, but multiple were provided: \#{active_renderers.join(", ")}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
result = case active_renderers.first
|
|
33
|
+
#{@__renderers.map do |renderer_name, method_name|
|
|
34
|
+
<<~RUBY
|
|
35
|
+
when :#{renderer_name}
|
|
36
|
+
#{method_name}(#{renderer_name}, **)
|
|
37
|
+
RUBY
|
|
38
|
+
end.join("\n")}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
return if @__rendered
|
|
42
|
+
render plain: result.to_s, status: (status || 200)
|
|
43
|
+
end
|
|
44
|
+
RUBY
|
|
45
|
+
end
|
|
46
|
+
# rubocop:enable all
|
|
47
|
+
end
|
|
@@ -44,8 +44,24 @@ class Rage::Deferred::Backends::Disk
|
|
|
44
44
|
@recovered_storages = storage_files[1..] if storage_files.length > 1
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
-
#
|
|
48
|
-
|
|
47
|
+
# include recovered storages from crashed/previous workers
|
|
48
|
+
all_storages = [@storage, *@recovered_storages].compact
|
|
49
|
+
|
|
50
|
+
# find the highest task timestamp across all storage files
|
|
51
|
+
storage_file_max_timestamp = all_storages.map do |storage|
|
|
52
|
+
max_timestamp = 0
|
|
53
|
+
storage.tap(&:rewind).each_line(chomp: true) do |entry|
|
|
54
|
+
next unless entry[9...12] == "add"
|
|
55
|
+
timestamp = entry[13..].split("-").first.to_i
|
|
56
|
+
max_timestamp = timestamp if timestamp > max_timestamp
|
|
57
|
+
end
|
|
58
|
+
max_timestamp
|
|
59
|
+
end.max.to_i
|
|
60
|
+
|
|
61
|
+
# apply Lamport IR2(b) From time, clocks and the ordering of
|
|
62
|
+
# events in a distributed system to guard against clock skew
|
|
63
|
+
task_id_seed = [Time.now.to_i, storage_file_max_timestamp].max + 1
|
|
64
|
+
|
|
49
65
|
@task_id_base, @task_id_i = "#{task_id_seed}-#{Process.pid}", 0
|
|
50
66
|
Iodine.run_every(1_000) do
|
|
51
67
|
task_id_seed += 1
|
|
@@ -117,7 +133,7 @@ class Rage::Deferred::Backends::Disk
|
|
|
117
133
|
# `@recovered_storages` will only be present if the server has previously crashed and left
|
|
118
134
|
# some storage files behind, or if the new cluster is started with fewer workers than before;
|
|
119
135
|
# TLDR: this code is expected to execute very rarely
|
|
120
|
-
@recovered_storages.each { |storage| recover_tasks(storage) }
|
|
136
|
+
@recovered_storages.each { |storage| recover_tasks(storage.tap(&:rewind)) }
|
|
121
137
|
end
|
|
122
138
|
|
|
123
139
|
tasks = {}
|
|
@@ -27,7 +27,7 @@ class Rage::Deferred::Metadata
|
|
|
27
27
|
# @return [Boolean] `true` if a failure will schedule another attempt, `false` otherwise
|
|
28
28
|
def will_retry?
|
|
29
29
|
task = Rage::Deferred::Context.get_task(context)
|
|
30
|
-
task.
|
|
30
|
+
!!task.__next_retry_in(attempts, nil)
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
private
|
data/lib/rage/deferred/queue.rb
CHANGED
|
@@ -38,14 +38,15 @@ class Rage::Deferred::Queue
|
|
|
38
38
|
Fiber.schedule do
|
|
39
39
|
Iodine.task_inc!
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
result = task.new.__perform(context)
|
|
42
42
|
|
|
43
|
-
if
|
|
43
|
+
if result == true
|
|
44
44
|
@backend.remove(task_id)
|
|
45
45
|
else
|
|
46
46
|
attempts = Rage::Deferred::Context.inc_attempts(context)
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
retry_in = task.__next_retry_in(attempts, result)
|
|
48
|
+
if retry_in
|
|
49
|
+
enqueue(context, delay: retry_in, task_id:)
|
|
49
50
|
else
|
|
50
51
|
@backend.remove(task_id)
|
|
51
52
|
end
|
data/lib/rage/deferred/task.rb
CHANGED
|
@@ -85,7 +85,7 @@ module Rage::Deferred::Task
|
|
|
85
85
|
Rage.logger.error("Deferred task failed with exception: #{e.class} (#{e.message}):\n#{e.backtrace.join("\n")}")
|
|
86
86
|
end
|
|
87
87
|
end
|
|
88
|
-
|
|
88
|
+
e
|
|
89
89
|
end
|
|
90
90
|
|
|
91
91
|
private def restore_log_info(context)
|
|
@@ -105,6 +105,62 @@ module Rage::Deferred::Task
|
|
|
105
105
|
end
|
|
106
106
|
|
|
107
107
|
module ClassMethods
|
|
108
|
+
# Set the maximum number of retry attempts for this task.
|
|
109
|
+
#
|
|
110
|
+
# @param count [Integer] the maximum number of retry attempts
|
|
111
|
+
# @example
|
|
112
|
+
# class SendWelcomeEmail
|
|
113
|
+
# include Rage::Deferred::Task
|
|
114
|
+
# max_retries 10
|
|
115
|
+
#
|
|
116
|
+
# def perform(email)
|
|
117
|
+
# # ...
|
|
118
|
+
# end
|
|
119
|
+
# end
|
|
120
|
+
def max_retries(count)
|
|
121
|
+
value = Integer(count)
|
|
122
|
+
|
|
123
|
+
if value.negative?
|
|
124
|
+
raise ArgumentError, "max_retries should be a valid non-negative integer"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
@__max_retries = value
|
|
128
|
+
rescue ArgumentError, TypeError
|
|
129
|
+
raise ArgumentError, "max_retries should be a valid non-negative integer"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Override this method to customize retry behavior per exception.
|
|
133
|
+
#
|
|
134
|
+
# Return an Integer to retry in that many seconds.
|
|
135
|
+
# Return `super` to use the default exponential backoff.
|
|
136
|
+
# Return `false` or `nil` to abort retries.
|
|
137
|
+
#
|
|
138
|
+
# @param exception [Exception] the exception that caused the failure
|
|
139
|
+
# @param attempt [Integer] the current attempt number (1-indexed)
|
|
140
|
+
# @return [Integer, false, nil] the retry interval in seconds, or false/nil to abort
|
|
141
|
+
# @example
|
|
142
|
+
# class ProcessPayment
|
|
143
|
+
# include Rage::Deferred::Task
|
|
144
|
+
#
|
|
145
|
+
# def self.retry_interval(exception, attempt:)
|
|
146
|
+
# case exception
|
|
147
|
+
# when TemporaryNetworkError
|
|
148
|
+
# 10 # Retry in 10 seconds
|
|
149
|
+
# when InvalidDataError
|
|
150
|
+
# false # Do not retry
|
|
151
|
+
# else
|
|
152
|
+
# super # Default backoff strategy
|
|
153
|
+
# end
|
|
154
|
+
# end
|
|
155
|
+
#
|
|
156
|
+
# def perform(payment_id)
|
|
157
|
+
# # ...
|
|
158
|
+
# end
|
|
159
|
+
# end
|
|
160
|
+
def retry_interval(exception, attempt:)
|
|
161
|
+
__default_backoff(attempt)
|
|
162
|
+
end
|
|
163
|
+
|
|
108
164
|
def enqueue(*args, delay: nil, delay_until: nil, **kwargs)
|
|
109
165
|
context = Rage::Deferred::Context.build(self, args, kwargs)
|
|
110
166
|
|
|
@@ -118,13 +174,24 @@ module Rage::Deferred::Task
|
|
|
118
174
|
end
|
|
119
175
|
|
|
120
176
|
# @private
|
|
121
|
-
def
|
|
122
|
-
|
|
177
|
+
def __next_retry_in(attempts, exception)
|
|
178
|
+
max = @__max_retries || MAX_ATTEMPTS
|
|
179
|
+
return if attempts > max
|
|
180
|
+
|
|
181
|
+
interval = retry_interval(exception, attempt: attempts)
|
|
182
|
+
return if !interval
|
|
183
|
+
|
|
184
|
+
unless interval.is_a?(Numeric)
|
|
185
|
+
Rage.logger.warn("#{name}.retry_interval returned #{interval.class}, expected Numeric, false, or nil; falling back to default backoff")
|
|
186
|
+
return __default_backoff(attempts)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
interval
|
|
123
190
|
end
|
|
124
191
|
|
|
125
192
|
# @private
|
|
126
|
-
def
|
|
127
|
-
rand(BACKOFF_INTERVAL * 2**
|
|
193
|
+
def __default_backoff(attempt)
|
|
194
|
+
rand(BACKOFF_INTERVAL * 2**attempt) + 1
|
|
128
195
|
end
|
|
129
196
|
end
|
|
130
197
|
end
|
data/lib/rage/errors.rb
CHANGED
data/lib/rage/internal.rb
CHANGED
|
@@ -44,6 +44,42 @@ class Rage::Internal
|
|
|
44
44
|
}.join(", ")
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
+
# Generate a stream name based on the provided object.
|
|
48
|
+
# @param streamables [#id, String, Symbol, Numeric, Array] an object that will be used to generate the stream name
|
|
49
|
+
# @return [String] the generated stream name
|
|
50
|
+
# @raise [ArgumentError] if the provided object cannot be used to generate a stream name
|
|
51
|
+
def stream_name_for(streamables)
|
|
52
|
+
return streamables if streamables.is_a?(String)
|
|
53
|
+
|
|
54
|
+
name_segments = Array(streamables).map do |streamable|
|
|
55
|
+
if streamable.respond_to?(:id)
|
|
56
|
+
"#{streamable.class.name}:#{streamable.id}"
|
|
57
|
+
elsif streamable.is_a?(String) || streamable.is_a?(Symbol) || streamable.is_a?(Numeric)
|
|
58
|
+
streamable
|
|
59
|
+
else
|
|
60
|
+
raise ArgumentError, "Unable to generate stream name. Expected an object that responds to `id`, got: #{streamable.class}"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
name_segments.join(":")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Pick a worker process to execute a block of code.
|
|
68
|
+
# This is useful for ensuring that certain code is only executed by a single worker in a multi-worker setup, e.g. for broadcasting messages to known streams or for running periodic tasks.
|
|
69
|
+
# @yield The block of code to be executed by the picked worker
|
|
70
|
+
def pick_a_worker(&block)
|
|
71
|
+
@lock_file, lock_path = Tempfile.new.yield_self { |file| [file, file.path] }
|
|
72
|
+
|
|
73
|
+
Iodine.on_state(:on_start) do
|
|
74
|
+
worker_lock = File.new(lock_path)
|
|
75
|
+
|
|
76
|
+
if worker_lock.flock(File::LOCK_EX | File::LOCK_NB)
|
|
77
|
+
@worker_lock = worker_lock
|
|
78
|
+
block.call
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
47
83
|
private
|
|
48
84
|
|
|
49
85
|
def dynamic_name_seed
|
data/lib/rage/logger/logger.rb
CHANGED
|
@@ -28,7 +28,7 @@ require "logger"
|
|
|
28
28
|
# # => [fecbba0735355738] timestamp=2023-10-19T11:12:56+00:00 pid=1825 level=info cache_key=mykey message=cache miss
|
|
29
29
|
# ```
|
|
30
30
|
#
|
|
31
|
-
# `Rage::Logger` also implements the interface of Ruby's native {https://ruby-doc.org/3.
|
|
31
|
+
# `Rage::Logger` also implements the interface of Ruby's native {https://ruby-doc.org/3.4.1/stdlibs/logger/Logger.html Logger}:
|
|
32
32
|
# ```ruby
|
|
33
33
|
# Rage.logger.info("Initializing")
|
|
34
34
|
# Rage.logger.debug { "This is a " + potentially + " expensive operation" }
|
|
@@ -56,7 +56,47 @@ class Rage::OpenAPI::Converter
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
if node.parameters.any?
|
|
59
|
-
|
|
59
|
+
has_file_param = node.parameters.values.any? { |p| !p.key?(:ref) && p[:type] && p[:type]["format"] == "binary" }
|
|
60
|
+
|
|
61
|
+
if has_file_param
|
|
62
|
+
schema_properties = {}
|
|
63
|
+
schema_required = []
|
|
64
|
+
query_ref_parameters = []
|
|
65
|
+
|
|
66
|
+
# When file params are present, non-ref params become part of the multipart/form-data
|
|
67
|
+
# request body schema. Shared refs (e.g., #/components/parameters/...) are kept as
|
|
68
|
+
# regular parameter references since we can't inline their definitions into the schema.
|
|
69
|
+
node.parameters.each do |param_name, param_info|
|
|
70
|
+
if param_info.key?(:ref)
|
|
71
|
+
# shared parameter refs stay as top-level parameters
|
|
72
|
+
query_ref_parameters << param_info[:ref]
|
|
73
|
+
else
|
|
74
|
+
# inline params become properties in the multipart schema
|
|
75
|
+
property_schema = get_param_type_spec(param_name, param_info[:type]).dup
|
|
76
|
+
if param_info[:description] && !param_info[:description].empty?
|
|
77
|
+
property_schema["description"] = param_info[:description]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
schema_properties[param_name] = property_schema
|
|
81
|
+
schema_required << param_name if param_info[:required]
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
memo[path][method]["requestBody"] = {
|
|
86
|
+
"content" => {
|
|
87
|
+
"multipart/form-data" => {
|
|
88
|
+
"schema" => {
|
|
89
|
+
"type" => "object",
|
|
90
|
+
"properties" => schema_properties
|
|
91
|
+
}.tap { |s| s["required"] = schema_required if schema_required.any? }
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
memo[path][method]["parameters"] = query_ref_parameters if query_ref_parameters.any?
|
|
97
|
+
else
|
|
98
|
+
memo[path][method]["parameters"] = build_parameters(node)
|
|
99
|
+
end
|
|
60
100
|
end
|
|
61
101
|
|
|
62
102
|
responses = node.parents.reverse.map(&:responses).reduce(&:merge).merge(node.responses)
|
|
@@ -79,7 +119,8 @@ class Rage::OpenAPI::Converter
|
|
|
79
119
|
if node.request.key?("$ref") && node.request["$ref"].start_with?("#/components/requestBodies")
|
|
80
120
|
memo[path][method]["requestBody"] = node.request
|
|
81
121
|
else
|
|
82
|
-
memo[path][method]["requestBody"]
|
|
122
|
+
memo[path][method]["requestBody"] ||= {}
|
|
123
|
+
(memo[path][method]["requestBody"]["content"] ||= {})["application/json"] = { "schema" => node.request }
|
|
83
124
|
end
|
|
84
125
|
end
|
|
85
126
|
end
|
data/lib/rage/openapi/openapi.rb
CHANGED
|
@@ -153,11 +153,22 @@ module Rage::OpenAPI
|
|
|
153
153
|
{ "type" => "string", "format" => "date-time" }
|
|
154
154
|
when "String"
|
|
155
155
|
{ "type" => "string" }
|
|
156
|
+
when "File"
|
|
157
|
+
{ "type" => "string", "format" => "binary" }
|
|
156
158
|
else
|
|
157
159
|
{ "type" => "string" } if default
|
|
158
160
|
end
|
|
159
161
|
end
|
|
160
162
|
|
|
163
|
+
# @private
|
|
164
|
+
def self.__resolve_resource(klass_str, namespace)
|
|
165
|
+
return nil if klass_str.nil?
|
|
166
|
+
namespace.const_get(klass_str)
|
|
167
|
+
rescue NameError
|
|
168
|
+
__log_warn("could not resolve resource: #{klass_str}")
|
|
169
|
+
nil
|
|
170
|
+
end
|
|
171
|
+
|
|
161
172
|
# @private
|
|
162
173
|
def self.__log_warn(log)
|
|
163
174
|
puts "[OpenAPI] WARNING: #{log}"
|
|
@@ -151,12 +151,13 @@ class Rage::OpenAPI::Parsers::Ext::Alba
|
|
|
151
151
|
with_inner_segment(key, is_array:) { visit(node.block) }
|
|
152
152
|
else
|
|
153
153
|
resource = context.keywords["resource"] || (::Alba.inflector && "#{::Alba.inflector.classify(association.to_s)}Resource")
|
|
154
|
-
|
|
154
|
+
resolved = Rage::OpenAPI.__resolve_resource(resource, @parser.namespace)
|
|
155
155
|
|
|
156
|
-
@segment[key] = if
|
|
157
|
-
@parser.__parse_nested(
|
|
156
|
+
@segment[key] = if resolved
|
|
157
|
+
is_array ? @parser.__parse_nested("[#{resource}]") : @parser.__parse_nested(resource)
|
|
158
158
|
else
|
|
159
|
-
|
|
159
|
+
base = { "type" => "object" }
|
|
160
|
+
is_array ? { "type" => "array", "items" => base } : base
|
|
160
161
|
end
|
|
161
162
|
end
|
|
162
163
|
|