sidekiq 7.1.4 → 7.1.6
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/Changes.md +20 -0
- data/README.md +2 -2
- data/lib/sidekiq/client.rb +5 -3
- data/lib/sidekiq/config.rb +13 -4
- data/lib/sidekiq/rails.rb +10 -15
- data/lib/sidekiq/redis_client_adapter.rb +1 -2
- data/lib/sidekiq/redis_connection.rb +1 -0
- data/lib/sidekiq/testing.rb +19 -6
- data/lib/sidekiq/version.rb +1 -1
- data/lib/sidekiq/web/application.rb +50 -0
- data/lib/sidekiq/web/helpers.rb +23 -9
- data/web/locales/en.yml +2 -0
- data/web/views/filtering.erb +7 -0
- metadata +4 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b50ab03f32263dd24ee37ce2ddadc7a4e5e226860146c1a677262dd3a417329d
|
|
4
|
+
data.tar.gz: 0af1051781796465d5ce5dd0d0e454016fdc04b7cb5fcf0b8d62e447faff11ed
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f2e84e49ceb024e8cb24a7213ffe81e40ed9423d646e39778ed51049bf89a04c28890a21e93eeb38bb9daefe88b94b150d71a0cdf80ecdad712a2167dd0a3421
|
|
7
|
+
data.tar.gz: 39ffc3e4eee3e2c27a3f774f2a2120c50a52ab9e2049b0fc925123c622553bf4fd19512c16e63882031909bba6ea5b2d15cf856256a3c453d080e89253196fc6
|
data/Changes.md
CHANGED
|
@@ -2,6 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
[Sidekiq Changes](https://github.com/sidekiq/sidekiq/blob/main/Changes.md) | [Sidekiq Pro Changes](https://github.com/sidekiq/sidekiq/blob/main/Pro-Changes.md) | [Sidekiq Enterprise Changes](https://github.com/sidekiq/sidekiq/blob/main/Ent-Changes.md)
|
|
4
4
|
|
|
5
|
+
7.1.6
|
|
6
|
+
----------
|
|
7
|
+
|
|
8
|
+
- The block forms of testing modes (inline, fake) are now thread-safe so you can have
|
|
9
|
+
a multithreaded test suite which uses different modes for different tests. [#6069]
|
|
10
|
+
- Fix breakage with non-Proc error handlers [#6065]
|
|
11
|
+
|
|
12
|
+
7.1.5
|
|
13
|
+
----------
|
|
14
|
+
|
|
15
|
+
- **FEATURE**: Job filtering within the Web UI. This feature has been open
|
|
16
|
+
sourced from Sidekiq Pro. [#6052]
|
|
17
|
+
- **API CHANGE** Error handlers now take three arguments `->(ex, context, config)`.
|
|
18
|
+
The previous calling convention will work until Sidekiq 8.0 but will print
|
|
19
|
+
out a deprecation warning. [#6051]
|
|
20
|
+
- Fix issue with the `batch_size` and `at` options in `S::Client.push_bulk` [#6040]
|
|
21
|
+
- Fix inline testing firing batch callbacks early [#6057]
|
|
22
|
+
- Use new log broadcast API in Rails 7.1 [#6054]
|
|
23
|
+
- Crash if user tries to use RESP2 `protocol: 2` [#6061]
|
|
24
|
+
|
|
5
25
|
7.1.4
|
|
6
26
|
----------
|
|
7
27
|
|
data/README.md
CHANGED
|
@@ -83,7 +83,7 @@ You can purchase at https://sidekiq.org; email support@contribsys.com for help.
|
|
|
83
83
|
Useful resources:
|
|
84
84
|
|
|
85
85
|
* Product documentation is in the [wiki](https://github.com/sidekiq/sidekiq/wiki).
|
|
86
|
-
* Occasional announcements are made to the [@sidekiq](https://
|
|
86
|
+
* Occasional announcements are made to the [@sidekiq](https://ruby.social/@sidekiq) Mastodon account.
|
|
87
87
|
* The [Sidekiq tag](https://stackoverflow.com/questions/tagged/sidekiq) on Stack Overflow has lots of useful Q & A.
|
|
88
88
|
|
|
89
89
|
Every Friday morning is Sidekiq office hour: I video chat and answer questions.
|
|
@@ -103,4 +103,4 @@ The license for Sidekiq Pro and Sidekiq Enterprise can be found in [COMM-LICENSE
|
|
|
103
103
|
Author
|
|
104
104
|
-----------------
|
|
105
105
|
|
|
106
|
-
Mike Perham, [@getajobmike](https://
|
|
106
|
+
Mike Perham, [@getajobmike](https://ruby.social/@getajobmike) / [@sidekiq](https://ruby.social/@sidekiq), [https://www.mikeperham.com](https://www.mikeperham.com) / [https://www.contribsys.com](https://www.contribsys.com)
|
data/lib/sidekiq/client.rb
CHANGED
|
@@ -74,7 +74,7 @@ module Sidekiq
|
|
|
74
74
|
#
|
|
75
75
|
# Any options valid for a job class's sidekiq_options are also available here.
|
|
76
76
|
#
|
|
77
|
-
# All
|
|
77
|
+
# All keys must be strings, not symbols. NB: because we are serializing to JSON, all
|
|
78
78
|
# symbols in 'args' will be converted to strings. Note that +backtrace: true+ can take quite a bit of
|
|
79
79
|
# space in Redis; a large volume of failing jobs can start Redis swapping if you aren't careful.
|
|
80
80
|
#
|
|
@@ -111,7 +111,7 @@ module Sidekiq
|
|
|
111
111
|
# prevented a job push.
|
|
112
112
|
#
|
|
113
113
|
# Example (pushing jobs in batches):
|
|
114
|
-
# push_bulk('class' =>
|
|
114
|
+
# push_bulk('class' => MyJob, 'args' => (1..100_000).to_a, batch_size: 1_000)
|
|
115
115
|
#
|
|
116
116
|
def push_bulk(items)
|
|
117
117
|
batch_size = items.delete(:batch_size) || items.delete("batch_size") || 1_000
|
|
@@ -124,19 +124,21 @@ module Sidekiq
|
|
|
124
124
|
raise ArgumentError, "Explicitly passing 'jid' when pushing more than one job is not supported" if jid && args.size > 1
|
|
125
125
|
|
|
126
126
|
normed = normalize_item(items)
|
|
127
|
+
slice_index = 0
|
|
127
128
|
result = args.each_slice(batch_size).flat_map do |slice|
|
|
128
129
|
raise ArgumentError, "Bulk arguments must be an Array of Arrays: [[1], [2]]" unless slice.is_a?(Array) && slice.all?(Array)
|
|
129
130
|
break [] if slice.empty? # no jobs to push
|
|
130
131
|
|
|
131
132
|
payloads = slice.map.with_index { |job_args, index|
|
|
132
133
|
copy = normed.merge("args" => job_args, "jid" => SecureRandom.hex(12))
|
|
133
|
-
copy["at"] = (at.is_a?(Array) ? at[index] : at) if at
|
|
134
|
+
copy["at"] = (at.is_a?(Array) ? at[slice_index + index] : at) if at
|
|
134
135
|
result = middleware.invoke(items["class"], copy, copy["queue"], @redis_pool) do
|
|
135
136
|
verify_json(copy)
|
|
136
137
|
copy
|
|
137
138
|
end
|
|
138
139
|
result || nil
|
|
139
140
|
}
|
|
141
|
+
slice_index += batch_size
|
|
140
142
|
|
|
141
143
|
to_push = payloads.compact
|
|
142
144
|
raw_push(to_push) unless to_push.empty?
|
data/lib/sidekiq/config.rb
CHANGED
|
@@ -34,8 +34,7 @@ module Sidekiq
|
|
|
34
34
|
backtrace_cleaner: ->(backtrace) { backtrace }
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
ERROR_HANDLER = ->(ex, ctx) {
|
|
38
|
-
cfg = ctx[:_config] || Sidekiq.default_configuration
|
|
37
|
+
ERROR_HANDLER = ->(ex, ctx, cfg = Sidekiq.default_configuration) {
|
|
39
38
|
l = cfg.logger
|
|
40
39
|
l.warn(Sidekiq.dump_json(ctx)) unless ctx.empty?
|
|
41
40
|
l.warn("#{ex.class.name}: #{ex.message}")
|
|
@@ -259,14 +258,24 @@ module Sidekiq
|
|
|
259
258
|
@logger = logger
|
|
260
259
|
end
|
|
261
260
|
|
|
261
|
+
private def arity(handler)
|
|
262
|
+
return handler.arity if handler.is_a?(Proc)
|
|
263
|
+
handler.method(:call).arity
|
|
264
|
+
end
|
|
265
|
+
|
|
262
266
|
# INTERNAL USE ONLY
|
|
263
267
|
def handle_exception(ex, ctx = {})
|
|
264
268
|
if @options[:error_handlers].size == 0
|
|
265
269
|
p ["!!!!!", ex]
|
|
266
270
|
end
|
|
267
|
-
ctx[:_config] = self
|
|
268
271
|
@options[:error_handlers].each do |handler|
|
|
269
|
-
handler
|
|
272
|
+
if arity(handler) == 2
|
|
273
|
+
# TODO Remove in 8.0
|
|
274
|
+
logger.info { "DEPRECATION: Sidekiq exception handlers now take three arguments, see #{handler}" }
|
|
275
|
+
handler.call(ex, {_config: self}.merge(ctx))
|
|
276
|
+
else
|
|
277
|
+
handler.call(ex, ctx, self)
|
|
278
|
+
end
|
|
270
279
|
rescue Exception => e
|
|
271
280
|
l = logger
|
|
272
281
|
l.error "!!! ERROR HANDLER THREW AN ERROR !!!"
|
data/lib/sidekiq/rails.rb
CHANGED
|
@@ -20,10 +20,6 @@ module Sidekiq
|
|
|
20
20
|
def inspect
|
|
21
21
|
"#<Sidekiq::Rails::Reloader @app=#{@app.class.name}>"
|
|
22
22
|
end
|
|
23
|
-
|
|
24
|
-
def to_json(*)
|
|
25
|
-
Sidekiq.dump_json(inspect)
|
|
26
|
-
end
|
|
27
23
|
end
|
|
28
24
|
|
|
29
25
|
# By including the Options module, we allow AJs to directly control sidekiq features
|
|
@@ -43,17 +39,6 @@ module Sidekiq
|
|
|
43
39
|
end
|
|
44
40
|
end
|
|
45
41
|
|
|
46
|
-
initializer "sidekiq.rails_logger" do
|
|
47
|
-
Sidekiq.configure_server do |config|
|
|
48
|
-
# This is the integration code necessary so that if a job uses `Rails.logger.info "Hello"`,
|
|
49
|
-
# it will appear in the Sidekiq console with all of the job context. See #5021 and
|
|
50
|
-
# https://github.com/rails/rails/blob/b5f2b550f69a99336482739000c58e4e04e033aa/railties/lib/rails/commands/server/server_command.rb#L82-L84
|
|
51
|
-
unless ::Rails.logger == config.logger || ::ActiveSupport::Logger.logger_outputs_to?(::Rails.logger, $stdout)
|
|
52
|
-
::Rails.logger.extend(::ActiveSupport::Logger.broadcast(config.logger))
|
|
53
|
-
end
|
|
54
|
-
end
|
|
55
|
-
end
|
|
56
|
-
|
|
57
42
|
initializer "sidekiq.backtrace_cleaner" do
|
|
58
43
|
Sidekiq.configure_server do |config|
|
|
59
44
|
config[:backtrace_cleaner] = ->(backtrace) { ::Rails.backtrace_cleaner.clean(backtrace) }
|
|
@@ -67,6 +52,16 @@ module Sidekiq
|
|
|
67
52
|
config.after_initialize do
|
|
68
53
|
Sidekiq.configure_server do |config|
|
|
69
54
|
config[:reloader] = Sidekiq::Rails::Reloader.new
|
|
55
|
+
|
|
56
|
+
# This is the integration code necessary so that if a job uses `Rails.logger.info "Hello"`,
|
|
57
|
+
# it will appear in the Sidekiq console with all of the job context.
|
|
58
|
+
unless ::Rails.logger == config.logger || ::ActiveSupport::Logger.logger_outputs_to?(::Rails.logger, $stdout)
|
|
59
|
+
if ::Rails::VERSION::STRING < "7.1"
|
|
60
|
+
::Rails.logger.extend(::ActiveSupport::Logger.broadcast(config.logger))
|
|
61
|
+
else
|
|
62
|
+
::Rails.logger.broadcast_to(config.logger)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
70
65
|
end
|
|
71
66
|
end
|
|
72
67
|
end
|
|
@@ -63,8 +63,7 @@ module Sidekiq
|
|
|
63
63
|
opts = options.dup
|
|
64
64
|
|
|
65
65
|
if opts[:namespace]
|
|
66
|
-
raise ArgumentError, "Your Redis configuration uses the namespace '#{opts[:namespace]}' but this feature
|
|
67
|
-
"Either use the redis adapter or remove the namespace."
|
|
66
|
+
raise ArgumentError, "Your Redis configuration uses the namespace '#{opts[:namespace]}' but this feature is no longer supported in Sidekiq 7+. See https://github.com/sidekiq/sidekiq/blob/main/docs/7.0-Upgrade.md#redis-namespace."
|
|
68
67
|
end
|
|
69
68
|
|
|
70
69
|
opts.delete(:size)
|
|
@@ -14,6 +14,7 @@ module Sidekiq
|
|
|
14
14
|
logger = symbolized_options.delete(:logger)
|
|
15
15
|
logger&.info { "Sidekiq #{Sidekiq::VERSION} connecting to Redis with options #{scrub(symbolized_options)}" }
|
|
16
16
|
|
|
17
|
+
raise "Sidekiq 7+ does not support Redis protocol 2" if symbolized_options[:protocol] == 2
|
|
17
18
|
size = symbolized_options.delete(:size) || 5
|
|
18
19
|
pool_timeout = symbolized_options.delete(:pool_timeout) || 1
|
|
19
20
|
pool_name = symbolized_options.delete(:pool_name)
|
data/lib/sidekiq/testing.rb
CHANGED
|
@@ -6,22 +6,35 @@ require "sidekiq"
|
|
|
6
6
|
module Sidekiq
|
|
7
7
|
class Testing
|
|
8
8
|
class << self
|
|
9
|
-
attr_accessor :
|
|
9
|
+
attr_accessor :__global_test_mode
|
|
10
10
|
|
|
11
|
+
# Calling without a block sets the global test mode, affecting
|
|
12
|
+
# all threads. Calling with a block only affects the current Thread.
|
|
11
13
|
def __set_test_mode(mode)
|
|
12
14
|
if block_given?
|
|
13
|
-
current_mode = __test_mode
|
|
14
15
|
begin
|
|
15
|
-
self.
|
|
16
|
+
self.__local_test_mode = mode
|
|
16
17
|
yield
|
|
17
18
|
ensure
|
|
18
|
-
self.
|
|
19
|
+
self.__local_test_mode = nil
|
|
19
20
|
end
|
|
20
21
|
else
|
|
21
|
-
self.
|
|
22
|
+
self.__global_test_mode = mode
|
|
22
23
|
end
|
|
23
24
|
end
|
|
24
25
|
|
|
26
|
+
def __test_mode
|
|
27
|
+
__local_test_mode || __global_test_mode
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def __local_test_mode
|
|
31
|
+
Thread.current[:__sidekiq_test_mode]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def __local_test_mode=(value)
|
|
35
|
+
Thread.current[:__sidekiq_test_mode] = value
|
|
36
|
+
end
|
|
37
|
+
|
|
25
38
|
def disable!(&block)
|
|
26
39
|
__set_test_mode(:disable, &block)
|
|
27
40
|
end
|
|
@@ -64,7 +77,7 @@ module Sidekiq
|
|
|
64
77
|
class EmptyQueueError < RuntimeError; end
|
|
65
78
|
|
|
66
79
|
module TestingClient
|
|
67
|
-
def
|
|
80
|
+
def atomic_push(conn, payloads)
|
|
68
81
|
if Sidekiq::Testing.fake?
|
|
69
82
|
payloads.each do |job|
|
|
70
83
|
job = Sidekiq.load_json(Sidekiq.dump_json(job))
|
data/lib/sidekiq/version.rb
CHANGED
|
@@ -328,6 +328,56 @@ module Sidekiq
|
|
|
328
328
|
json Sidekiq::Stats.new.queues
|
|
329
329
|
end
|
|
330
330
|
|
|
331
|
+
########
|
|
332
|
+
# Filtering
|
|
333
|
+
get "/filter/retries" do
|
|
334
|
+
x = params[:substr]
|
|
335
|
+
return redirect "#{root_path}retries" unless x && x != ""
|
|
336
|
+
|
|
337
|
+
@retries = search(Sidekiq::RetrySet.new, params[:substr])
|
|
338
|
+
erb :retries
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
post "/filter/retries" do
|
|
342
|
+
x = params[:substr]
|
|
343
|
+
return redirect "#{root_path}retries" unless x && x != ""
|
|
344
|
+
|
|
345
|
+
@retries = search(Sidekiq::RetrySet.new, params[:substr])
|
|
346
|
+
erb :retries
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
get "/filter/scheduled" do
|
|
350
|
+
x = params[:substr]
|
|
351
|
+
return redirect "#{root_path}scheduled" unless x && x != ""
|
|
352
|
+
|
|
353
|
+
@scheduled = search(Sidekiq::ScheduledSet.new, params[:substr])
|
|
354
|
+
erb :scheduled
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
post "/filter/scheduled" do
|
|
358
|
+
x = params[:substr]
|
|
359
|
+
return redirect "#{root_path}scheduled" unless x && x != ""
|
|
360
|
+
|
|
361
|
+
@scheduled = search(Sidekiq::ScheduledSet.new, params[:substr])
|
|
362
|
+
erb :scheduled
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
get "/filter/dead" do
|
|
366
|
+
x = params[:substr]
|
|
367
|
+
return redirect "#{root_path}morgue" unless x && x != ""
|
|
368
|
+
|
|
369
|
+
@dead = search(Sidekiq::DeadSet.new, params[:substr])
|
|
370
|
+
erb :morgue
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
post "/filter/dead" do
|
|
374
|
+
x = params[:substr]
|
|
375
|
+
return redirect "#{root_path}morgue" unless x && x != ""
|
|
376
|
+
|
|
377
|
+
@dead = search(Sidekiq::DeadSet.new, params[:substr])
|
|
378
|
+
erb :morgue
|
|
379
|
+
end
|
|
380
|
+
|
|
331
381
|
def call(env)
|
|
332
382
|
action = self.class.match(env)
|
|
333
383
|
return [404, {Rack::CONTENT_TYPE => "text/plain", Web::X_CASCADE => "pass"}, ["Not Found"]] unless action
|
data/lib/sidekiq/web/helpers.rb
CHANGED
|
@@ -49,8 +49,29 @@ module Sidekiq
|
|
|
49
49
|
locale_files.select { |file| file =~ /\/#{lang}\.yml$/ }
|
|
50
50
|
end
|
|
51
51
|
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
def search(jobset, substr)
|
|
53
|
+
resultset = jobset.scan(substr).to_a
|
|
54
|
+
@current_page = 1
|
|
55
|
+
@count = @total_size = resultset.size
|
|
56
|
+
resultset
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def filtering(which)
|
|
60
|
+
erb(:filtering, locals: {which: which})
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def filter_link(jid, within = "retries")
|
|
64
|
+
if within.nil?
|
|
65
|
+
::Rack::Utils.escape_html(jid)
|
|
66
|
+
else
|
|
67
|
+
"<a href='#{root_path}filter/#{within}?substr=#{jid}'>#{::Rack::Utils.escape_html(jid)}</a>"
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def display_tags(job, within = "retries")
|
|
72
|
+
job.tags.map { |tag|
|
|
73
|
+
"<span class='label label-info jobtag'>#{filter_link(tag, within)}</span>"
|
|
74
|
+
}.join(" ")
|
|
54
75
|
end
|
|
55
76
|
|
|
56
77
|
# This view helper provide ability display you html code in
|
|
@@ -111,13 +132,6 @@ module Sidekiq
|
|
|
111
132
|
end
|
|
112
133
|
end
|
|
113
134
|
|
|
114
|
-
# within is used by Sidekiq Pro
|
|
115
|
-
def display_tags(job, within = nil)
|
|
116
|
-
job.tags.map { |tag|
|
|
117
|
-
"<span class='label label-info jobtag'>#{::Rack::Utils.escape_html(tag)}</span>"
|
|
118
|
-
}.join(" ")
|
|
119
|
-
end
|
|
120
|
-
|
|
121
135
|
# sidekiq/sidekiq#3243
|
|
122
136
|
def unfiltered?
|
|
123
137
|
yield unless env["PATH_INFO"].start_with?("/filter/")
|
data/web/locales/en.yml
CHANGED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<div class="sm-col-3 pull-right" style="display: inline; margin: 25px 15px 0 0;">
|
|
2
|
+
<%= t('Filter') %>:
|
|
3
|
+
<form method="POST" action='<%= root_path %>filter/<%= which %>' style="display: inline-block">
|
|
4
|
+
<%= csrf_tag %>
|
|
5
|
+
<input class="search" type="search" name="substr" value="<%= h params[:substr] %>" placeholder="<%= t('AnyJobContent') %>"/>
|
|
6
|
+
</form>
|
|
7
|
+
</div>
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: sidekiq
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 7.1.
|
|
4
|
+
version: 7.1.6
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Mike Perham
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2023-09
|
|
11
|
+
date: 2023-10-09 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: redis-client
|
|
@@ -187,6 +187,7 @@ files:
|
|
|
187
187
|
- web/views/busy.erb
|
|
188
188
|
- web/views/dashboard.erb
|
|
189
189
|
- web/views/dead.erb
|
|
190
|
+
- web/views/filtering.erb
|
|
190
191
|
- web/views/layout.erb
|
|
191
192
|
- web/views/metrics.erb
|
|
192
193
|
- web/views/metrics_for_job.erb
|
|
@@ -222,7 +223,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
222
223
|
- !ruby/object:Gem::Version
|
|
223
224
|
version: '0'
|
|
224
225
|
requirements: []
|
|
225
|
-
rubygems_version: 3.4.
|
|
226
|
+
rubygems_version: 3.4.20
|
|
226
227
|
signing_key:
|
|
227
228
|
specification_version: 4
|
|
228
229
|
summary: Simple, efficient background processing for Ruby
|