gitlab-sidekiq-fetcher 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: f10159f679879ed622c3bbd336bb04d0da0c54d536b7270c8c81dcc1243cedfc
4
+ data.tar.gz: cbca779aacb710b4f8de222799ca88860f3745666d22b51ea3d7da7f5677f8a0
5
+ SHA512:
6
+ metadata.gz: dd8376c3c379c325db87519e43f3ea36f96063b03b8884dc45363ff949cf3cda5341b20641f27dbb90be8a2188c0588ec92eb048b4055702aac6f7419f595a49
7
+ data.tar.gz: 02b0b4746cbe42960b89fc3b1b7f861f87b45317f6ae1f1f76d737333f80ac67c1553f6a8fdd3d44cacda87c08de88f9dee9b227a6d4b021d8bd58cacaa7fdee
data/.gitignore ADDED
@@ -0,0 +1,2 @@
1
+ *.gem
2
+ coverage
data/.gitlab-ci.yml ADDED
@@ -0,0 +1,53 @@
1
+ image: "ruby:2.5"
2
+
3
+ before_script:
4
+ - ruby -v
5
+ - which ruby
6
+ - gem install bundler --no-ri --no-rdoc
7
+ - bundle install --jobs $(nproc) "${FLAGS[@]}"
8
+
9
+ variables:
10
+ REDIS_URL: "redis://redis"
11
+
12
+ rspec:
13
+ stage: test
14
+ coverage: '/LOC \((\d+\.\d+%)\) covered.$/'
15
+ script:
16
+ - bundle exec rspec
17
+ services:
18
+ - redis:alpine
19
+ artifacts:
20
+ expire_in: 31d
21
+ when: always
22
+ paths:
23
+ - coverage/
24
+
25
+ .integration:
26
+ stage: test
27
+ script:
28
+ - cd test
29
+ - bundle exec ruby reliability_test.rb
30
+ services:
31
+ - redis:alpine
32
+
33
+ integration_semi:
34
+ extends: .integration
35
+ variables:
36
+ JOB_FETCHER: semi
37
+
38
+ integration_reliable:
39
+ extends: .integration
40
+ variables:
41
+ JOB_FETCHER: reliable
42
+
43
+
44
+ integration_basic:
45
+ extends: .integration
46
+ allow_failure: yes
47
+ variables:
48
+ JOB_FETCHER: basic
49
+
50
+
51
+ # rubocop:
52
+ # script:
53
+ # - bundle exec rubocop
data/.rspec ADDED
@@ -0,0 +1 @@
1
+ --require spec_helper
data/Gemfile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ git_source(:github) { |repo_name| "https://github.com/#{repo_name}" }
6
+
7
+ group :test do
8
+ gem "rspec", '~> 3'
9
+ gem "pry"
10
+ gem "sidekiq", '~> 5.0'
11
+ gem 'simplecov', require: false
12
+ end
data/Gemfile.lock ADDED
@@ -0,0 +1,50 @@
1
+ GEM
2
+ remote: https://rubygems.org/
3
+ specs:
4
+ coderay (1.1.2)
5
+ connection_pool (2.2.2)
6
+ diff-lcs (1.3)
7
+ docile (1.3.1)
8
+ json (2.1.0)
9
+ method_source (0.9.0)
10
+ pry (0.11.3)
11
+ coderay (~> 1.1.0)
12
+ method_source (~> 0.9.0)
13
+ rack (2.0.5)
14
+ rack-protection (2.0.4)
15
+ rack
16
+ redis (4.0.2)
17
+ rspec (3.8.0)
18
+ rspec-core (~> 3.8.0)
19
+ rspec-expectations (~> 3.8.0)
20
+ rspec-mocks (~> 3.8.0)
21
+ rspec-core (3.8.0)
22
+ rspec-support (~> 3.8.0)
23
+ rspec-expectations (3.8.1)
24
+ diff-lcs (>= 1.2.0, < 2.0)
25
+ rspec-support (~> 3.8.0)
26
+ rspec-mocks (3.8.0)
27
+ diff-lcs (>= 1.2.0, < 2.0)
28
+ rspec-support (~> 3.8.0)
29
+ rspec-support (3.8.0)
30
+ sidekiq (5.2.2)
31
+ connection_pool (~> 2.2, >= 2.2.2)
32
+ rack-protection (>= 1.5.0)
33
+ redis (>= 3.3.5, < 5)
34
+ simplecov (0.16.1)
35
+ docile (~> 1.1)
36
+ json (>= 1.8, < 3)
37
+ simplecov-html (~> 0.10.0)
38
+ simplecov-html (0.10.2)
39
+
40
+ PLATFORMS
41
+ ruby
42
+
43
+ DEPENDENCIES
44
+ pry
45
+ rspec (~> 3)
46
+ sidekiq (~> 5.0)
47
+ simplecov
48
+
49
+ BUNDLED WITH
50
+ 1.17.1
data/LICENSE ADDED
@@ -0,0 +1,165 @@
1
+ GNU LESSER GENERAL PUBLIC LICENSE
2
+ Version 3, 29 June 2007
3
+
4
+ Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
5
+ Everyone is permitted to copy and distribute verbatim copies
6
+ of this license document, but changing it is not allowed.
7
+
8
+
9
+ This version of the GNU Lesser General Public License incorporates
10
+ the terms and conditions of version 3 of the GNU General Public
11
+ License, supplemented by the additional permissions listed below.
12
+
13
+ 0. Additional Definitions.
14
+
15
+ As used herein, "this License" refers to version 3 of the GNU Lesser
16
+ General Public License, and the "GNU GPL" refers to version 3 of the GNU
17
+ General Public License.
18
+
19
+ "The Library" refers to a covered work governed by this License,
20
+ other than an Application or a Combined Work as defined below.
21
+
22
+ An "Application" is any work that makes use of an interface provided
23
+ by the Library, but which is not otherwise based on the Library.
24
+ Defining a subclass of a class defined by the Library is deemed a mode
25
+ of using an interface provided by the Library.
26
+
27
+ A "Combined Work" is a work produced by combining or linking an
28
+ Application with the Library. The particular version of the Library
29
+ with which the Combined Work was made is also called the "Linked
30
+ Version".
31
+
32
+ The "Minimal Corresponding Source" for a Combined Work means the
33
+ Corresponding Source for the Combined Work, excluding any source code
34
+ for portions of the Combined Work that, considered in isolation, are
35
+ based on the Application, and not on the Linked Version.
36
+
37
+ The "Corresponding Application Code" for a Combined Work means the
38
+ object code and/or source code for the Application, including any data
39
+ and utility programs needed for reproducing the Combined Work from the
40
+ Application, but excluding the System Libraries of the Combined Work.
41
+
42
+ 1. Exception to Section 3 of the GNU GPL.
43
+
44
+ You may convey a covered work under sections 3 and 4 of this License
45
+ without being bound by section 3 of the GNU GPL.
46
+
47
+ 2. Conveying Modified Versions.
48
+
49
+ If you modify a copy of the Library, and, in your modifications, a
50
+ facility refers to a function or data to be supplied by an Application
51
+ that uses the facility (other than as an argument passed when the
52
+ facility is invoked), then you may convey a copy of the modified
53
+ version:
54
+
55
+ a) under this License, provided that you make a good faith effort to
56
+ ensure that, in the event an Application does not supply the
57
+ function or data, the facility still operates, and performs
58
+ whatever part of its purpose remains meaningful, or
59
+
60
+ b) under the GNU GPL, with none of the additional permissions of
61
+ this License applicable to that copy.
62
+
63
+ 3. Object Code Incorporating Material from Library Header Files.
64
+
65
+ The object code form of an Application may incorporate material from
66
+ a header file that is part of the Library. You may convey such object
67
+ code under terms of your choice, provided that, if the incorporated
68
+ material is not limited to numerical parameters, data structure
69
+ layouts and accessors, or small macros, inline functions and templates
70
+ (ten or fewer lines in length), you do both of the following:
71
+
72
+ a) Give prominent notice with each copy of the object code that the
73
+ Library is used in it and that the Library and its use are
74
+ covered by this License.
75
+
76
+ b) Accompany the object code with a copy of the GNU GPL and this license
77
+ document.
78
+
79
+ 4. Combined Works.
80
+
81
+ You may convey a Combined Work under terms of your choice that,
82
+ taken together, effectively do not restrict modification of the
83
+ portions of the Library contained in the Combined Work and reverse
84
+ engineering for debugging such modifications, if you also do each of
85
+ the following:
86
+
87
+ a) Give prominent notice with each copy of the Combined Work that
88
+ the Library is used in it and that the Library and its use are
89
+ covered by this License.
90
+
91
+ b) Accompany the Combined Work with a copy of the GNU GPL and this license
92
+ document.
93
+
94
+ c) For a Combined Work that displays copyright notices during
95
+ execution, include the copyright notice for the Library among
96
+ these notices, as well as a reference directing the user to the
97
+ copies of the GNU GPL and this license document.
98
+
99
+ d) Do one of the following:
100
+
101
+ 0) Convey the Minimal Corresponding Source under the terms of this
102
+ License, and the Corresponding Application Code in a form
103
+ suitable for, and under terms that permit, the user to
104
+ recombine or relink the Application with a modified version of
105
+ the Linked Version to produce a modified Combined Work, in the
106
+ manner specified by section 6 of the GNU GPL for conveying
107
+ Corresponding Source.
108
+
109
+ 1) Use a suitable shared library mechanism for linking with the
110
+ Library. A suitable mechanism is one that (a) uses at run time
111
+ a copy of the Library already present on the user's computer
112
+ system, and (b) will operate properly with a modified version
113
+ of the Library that is interface-compatible with the Linked
114
+ Version.
115
+
116
+ e) Provide Installation Information, but only if you would otherwise
117
+ be required to provide such information under section 6 of the
118
+ GNU GPL, and only to the extent that such information is
119
+ necessary to install and execute a modified version of the
120
+ Combined Work produced by recombining or relinking the
121
+ Application with a modified version of the Linked Version. (If
122
+ you use option 4d0, the Installation Information must accompany
123
+ the Minimal Corresponding Source and Corresponding Application
124
+ Code. If you use option 4d1, you must provide the Installation
125
+ Information in the manner specified by section 6 of the GNU GPL
126
+ for conveying Corresponding Source.)
127
+
128
+ 5. Combined Libraries.
129
+
130
+ You may place library facilities that are a work based on the
131
+ Library side by side in a single library together with other library
132
+ facilities that are not Applications and are not covered by this
133
+ License, and convey such a combined library under terms of your
134
+ choice, if you do both of the following:
135
+
136
+ a) Accompany the combined library with a copy of the same work based
137
+ on the Library, uncombined with any other library facilities,
138
+ conveyed under the terms of this License.
139
+
140
+ b) Give prominent notice with the combined library that part of it
141
+ is a work based on the Library, and explaining where to find the
142
+ accompanying uncombined form of the same work.
143
+
144
+ 6. Revised Versions of the GNU Lesser General Public License.
145
+
146
+ The Free Software Foundation may publish revised and/or new versions
147
+ of the GNU Lesser General Public License from time to time. Such new
148
+ versions will be similar in spirit to the present version, but may
149
+ differ in detail to address new problems or concerns.
150
+
151
+ Each version is given a distinguishing version number. If the
152
+ Library as you received it specifies that a certain numbered version
153
+ of the GNU Lesser General Public License "or any later version"
154
+ applies to it, you have the option of following the terms and
155
+ conditions either of that published version or of any later version
156
+ published by the Free Software Foundation. If the Library as you
157
+ received it does not specify a version number of the GNU Lesser
158
+ General Public License, you may choose any version of the GNU Lesser
159
+ General Public License ever published by the Free Software Foundation.
160
+
161
+ If the Library as you received it specifies that a proxy can decide
162
+ whether future versions of the GNU Lesser General Public License shall
163
+ apply, that proxy's public statement of acceptance of any version is
164
+ permanent authorization for you to choose that version for the
165
+ Library.
data/README.md ADDED
@@ -0,0 +1,46 @@
1
+ gitlab-sidekiq-fetcher
2
+ ======================
3
+
4
+ `gitlab-sidekiq-fetcher` is an extension to Sidekiq that adds support for reliable
5
+ fetches from Redis.
6
+
7
+ It's based on https://github.com/TEA-ebook/sidekiq-reliable-fetch.
8
+
9
+ There are two strategies implemented: [Reliable fetch](http://redis.io/commands/rpoplpush#pattern-reliable-queue) using `rpoplpush` command and
10
+ semi-reliable fetch that uses regular `brpop` and `lpush` to pick the job and put it to working queue. The main benefit of "Reliable" strategy is that `rpoplpush` is atomic, eliminating a race condition in which jobs can be lost.
11
+ However, it comes at a cost because `rpoplpush` can't watch multiple lists at the same time so we need to iterate over the entire queue list which significantly increases pressure on Redis when there are more than a few queues. The "semi-reliable" strategy is much more reliable than the default Sidekiq fetcher, though. Compared to the reliable fetch strategy, it does not increase pressure on Redis significantly.
12
+
13
+
14
+ ## Installation
15
+
16
+ Add the following to your `Gemfile`:
17
+
18
+ ```ruby
19
+ gem 'gitlab-sidekiq-fetcher', require: 'sidekiq-reliable-fetch'
20
+ ```
21
+
22
+ ## Configuration
23
+
24
+ Enable reliable fetches by calling this gem from your Sidekiq configuration:
25
+
26
+ ```ruby
27
+ Sidekiq.configure_server do |config|
28
+ Sidekiq::ReliableFetch.setup_reliable_fetch!(config)
29
+
30
+ # …
31
+ end
32
+ ```
33
+
34
+ There is an additional parameter `config.options[:semi_reliable_fetch]` you can use to switch between two strategies:
35
+
36
+ ```ruby
37
+ Sidekiq.configure_server do |config|
38
+ config.options[:semi_reliable_fetch] = true # Default value is false
39
+
40
+ Sidekiq::ReliableFetch.setup_reliable_fetch!(config)
41
+ end
42
+ ```
43
+
44
+ ## License
45
+
46
+ LGPL-3.0, see the LICENSE file.
data/RELEASE-GITLAB.md ADDED
@@ -0,0 +1,9 @@
1
+ gitlab-sidekiq-fetcher
2
+ ======================
3
+
4
+ # How to publish a new release?
5
+
6
+ 1. Dev-commit cycle
7
+ 2. Update the version in the gemspec file, commit and tag
8
+ 3. Build the gem: `gem build gitlab-sidekiq-fetcher.gemspec`
9
+ 4. Upload the gem: `gem push gitlab-sidekiq-fetcher-X.X.X.gem`
@@ -0,0 +1,14 @@
1
+ Gem::Specification.new do |s|
2
+ s.name = 'gitlab-sidekiq-fetcher'
3
+ s.version = '0.1.0'
4
+ s.authors = ['TEA', 'GitLab']
5
+ s.email = 'valery@gitlab.com'
6
+ s.license = 'LGPL-3.0'
7
+ s.homepage = 'https://gitlab.com/gitlab-org/sidekiq-reliable-fetch/'
8
+ s.summary = 'Reliable fetch extension for Sidekiq'
9
+ s.description = 'Redis reliable queue pattern implemented in Sidekiq'
10
+ s.require_paths = ['lib']
11
+ s.files = `git ls-files`.split($\)
12
+ s.test_files = []
13
+ s.add_dependency 'sidekiq', '~> 5'
14
+ end
@@ -0,0 +1,5 @@
1
+ require 'sidekiq'
2
+
3
+ require_relative 'sidekiq/base_reliable_fetch'
4
+ require_relative 'sidekiq/reliable_fetch'
5
+ require_relative 'sidekiq/semi_reliable_fetch'
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sidekiq
4
+ class BaseReliableFetch
5
+ DEFAULT_CLEANUP_INTERVAL = 60 * 60 # 1 hour
6
+ HEARTBEAT_INTERVAL = 20 # seconds
7
+ HEARTBEAT_LIFESPAN = 60 # seconds
8
+ HEARTBEAT_RETRY_DELAY = 1 # seconds
9
+ WORKING_QUEUE_PREFIX = 'working'
10
+
11
+ # Defines how often we try to take a lease to not flood our
12
+ # Redis server with SET requests
13
+ DEFAULT_LEASE_INTERVAL = 2 * 60 # seconds
14
+ LEASE_KEY = 'reliable-fetcher-cleanup-lock'
15
+
16
+ # Defines the COUNT parameter that will be passed to Redis SCAN command
17
+ SCAN_COUNT = 1000
18
+
19
+ UnitOfWork = Struct.new(:queue, :job) do
20
+ def acknowledge
21
+ Sidekiq.redis { |conn| conn.lrem(Sidekiq::BaseReliableFetch.working_queue_name(queue), 1, job) }
22
+ end
23
+
24
+ def queue_name
25
+ queue.sub(/.*queue:/, '')
26
+ end
27
+
28
+ def requeue
29
+ Sidekiq.redis do |conn|
30
+ conn.multi do |multi|
31
+ multi.lpush(queue, job)
32
+ multi.lrem(Sidekiq::BaseReliableFetch.working_queue_name(queue), 1, job)
33
+ end
34
+ end
35
+ end
36
+ end
37
+
38
+ def self.setup_reliable_fetch!(config)
39
+ config.options[:fetch] = if config.options[:semi_reliable_fetch]
40
+ Sidekiq::SemiReliableFetch
41
+ else
42
+ Sidekiq::ReliableFetch
43
+ end
44
+
45
+ Sidekiq.logger.info('GitLab reliable fetch activated!')
46
+
47
+ start_heartbeat_thread
48
+ end
49
+
50
+ def self.start_heartbeat_thread
51
+ Thread.new do
52
+ loop do
53
+ begin
54
+ heartbeat
55
+
56
+ sleep HEARTBEAT_INTERVAL
57
+ rescue => e
58
+ Sidekiq.logger.error("Heartbeat thread error: #{e.message}")
59
+
60
+ sleep HEARTBEAT_RETRY_DELAY
61
+ end
62
+ end
63
+ end
64
+ end
65
+
66
+ def self.pid
67
+ @pid ||= ::Process.pid
68
+ end
69
+
70
+ def self.hostname
71
+ @hostname ||= Socket.gethostname
72
+ end
73
+
74
+ def self.heartbeat
75
+ Sidekiq.redis do |conn|
76
+ conn.set(heartbeat_key(hostname, pid), 1, ex: HEARTBEAT_LIFESPAN)
77
+ end
78
+
79
+ Sidekiq.logger.debug("Heartbeat for hostname: #{hostname} and pid: #{pid}")
80
+ end
81
+
82
+ def self.bulk_requeue(inprogress, _options)
83
+ return if inprogress.empty?
84
+
85
+ Sidekiq.logger.debug('Re-queueing terminated jobs')
86
+
87
+ Sidekiq.redis do |conn|
88
+ inprogress.each do |unit_of_work|
89
+ conn.multi do |multi|
90
+ multi.lpush(unit_of_work.queue, unit_of_work.job)
91
+ multi.lrem(working_queue_name(unit_of_work.queue), 1, unit_of_work.job)
92
+ end
93
+ end
94
+ end
95
+
96
+ Sidekiq.logger.info("Pushed #{inprogress.size} jobs back to Redis")
97
+ rescue => e
98
+ Sidekiq.logger.warn("Failed to requeue #{inprogress.size} jobs: #{e.message}")
99
+ end
100
+
101
+ def self.heartbeat_key(hostname, pid)
102
+ "reliable-fetcher-heartbeat-#{hostname}-#{pid}"
103
+ end
104
+
105
+ def self.working_queue_name(queue)
106
+ "#{WORKING_QUEUE_PREFIX}:#{queue}:#{hostname}:#{pid}"
107
+ end
108
+
109
+ attr_reader :cleanup_interval, :last_try_to_take_lease_at, :lease_interval,
110
+ :queues, :use_semi_reliable_fetch,
111
+ :strictly_ordered_queues
112
+
113
+ def initialize(options)
114
+ @cleanup_interval = options.fetch(:cleanup_interval, DEFAULT_CLEANUP_INTERVAL)
115
+ @lease_interval = options.fetch(:lease_interval, DEFAULT_LEASE_INTERVAL)
116
+ @last_try_to_take_lease_at = 0
117
+ @strictly_ordered_queues = !!options[:strict]
118
+ @queues = options[:queues].map { |q| "queue:#{q}" }
119
+ end
120
+
121
+ def retrieve_work
122
+ clean_working_queues! if take_lease
123
+
124
+ retrieve_unit_of_work
125
+ end
126
+
127
+ def retrieve_unit_of_work
128
+ raise NotImplementedError,
129
+ "#{self.class} does not implement #{__method__}"
130
+ end
131
+
132
+ private
133
+
134
+ def clean_working_queue!(working_queue)
135
+ original_queue = working_queue.gsub(/#{WORKING_QUEUE_PREFIX}:|:[^:]*:[0-9]*\z/, '')
136
+
137
+ Sidekiq.redis do |conn|
138
+ count = 0
139
+
140
+ while conn.rpoplpush(working_queue, original_queue) do
141
+ count += 1
142
+ end
143
+
144
+ Sidekiq.logger.info("Requeued #{count} dead jobs to #{original_queue}")
145
+ end
146
+ end
147
+
148
+ # Detect "old" jobs and requeue them because the worker they were assigned
149
+ # to probably failed miserably.
150
+ def clean_working_queues!
151
+ Sidekiq.logger.info("Cleaning working queues")
152
+
153
+ Sidekiq.redis do |conn|
154
+ conn.scan_each(match: "#{WORKING_QUEUE_PREFIX}:queue:*", count: SCAN_COUNT) do |key|
155
+ # Example: "working:name_of_the_job:queue:{hostname}:{PID}"
156
+ hostname, pid = key.scan(/:([^:]*):([0-9]*)\z/).flatten
157
+
158
+ continue if hostname.nil? || pid.nil?
159
+
160
+ clean_working_queue!(key) if worker_dead?(hostname, pid)
161
+ end
162
+ end
163
+ end
164
+
165
+ def worker_dead?(hostname, pid)
166
+ Sidekiq.redis do |conn|
167
+ !conn.get(self.class.heartbeat_key(hostname, pid))
168
+ end
169
+ end
170
+
171
+ def take_lease
172
+ return unless allowed_to_take_a_lease?
173
+
174
+ @last_try_to_take_lease_at = Time.now.to_f
175
+
176
+ Sidekiq.redis do |conn|
177
+ conn.set(LEASE_KEY, 1, nx: true, ex: cleanup_interval)
178
+ end
179
+ end
180
+
181
+ def allowed_to_take_a_lease?
182
+ Time.now.to_f - last_try_to_take_lease_at > lease_interval
183
+ end
184
+ end
185
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sidekiq
4
+ class ReliableFetch < BaseReliableFetch
5
+ # For reliable fetch we don't use Redis' blocking operations so
6
+ # we inject a regular sleep into the loop.
7
+ RELIABLE_FETCH_IDLE_TIMEOUT = 5 # seconds
8
+
9
+ attr_reader :queues_iterator, :queues_size
10
+
11
+ def initialize(options)
12
+ super
13
+
14
+ @queues_size = queues.size
15
+ @queues_iterator = queues.cycle
16
+ end
17
+
18
+ private
19
+
20
+ def retrieve_unit_of_work
21
+ @queues_iterator.rewind if strictly_ordered_queues
22
+
23
+ queues_size.times do
24
+ queue = queues_iterator.next
25
+
26
+ work = Sidekiq.redis do |conn|
27
+ conn.rpoplpush(queue, self.class.working_queue_name(queue))
28
+ end
29
+
30
+ return UnitOfWork.new(queue, work) if work
31
+ end
32
+
33
+ # We didn't find a job in any of the configured queues. Let's sleep a bit
34
+ # to avoid uselessly burning too much CPU
35
+ sleep(RELIABLE_FETCH_IDLE_TIMEOUT)
36
+
37
+ nil
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sidekiq
4
+ class SemiReliableFetch < BaseReliableFetch
5
+ # We want the fetch operation to timeout every few seconds so the thread
6
+ # can check if the process is shutting down. This constant is only used
7
+ # for semi-reliable fetch.
8
+ SEMI_RELIABLE_FETCH_TIMEOUT = 2 # seconds
9
+
10
+ def initialize(options)
11
+ super
12
+
13
+ if strictly_ordered_queues
14
+ @queues = @queues.uniq
15
+ @queues << SEMI_RELIABLE_FETCH_TIMEOUT
16
+ end
17
+ end
18
+
19
+ private
20
+
21
+ def retrieve_unit_of_work
22
+ work = Sidekiq.redis { |conn| conn.brpop(*queues_cmd) }
23
+ return unless work
24
+
25
+ unit_of_work = UnitOfWork.new(*work)
26
+
27
+ Sidekiq.redis do |conn|
28
+ conn.lpush(self.class.working_queue_name(unit_of_work.queue), unit_of_work.job)
29
+ end
30
+
31
+ unit_of_work
32
+ end
33
+
34
+ def queues_cmd
35
+ if strictly_ordered_queues
36
+ @queues
37
+ else
38
+ queues = @queues.shuffle.uniq
39
+ queues << SEMI_RELIABLE_FETCH_TIMEOUT
40
+ queues
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,73 @@
1
+ require 'spec_helper'
2
+ require 'fetch_shared_examples'
3
+ require 'sidekiq/base_reliable_fetch'
4
+ require 'sidekiq/reliable_fetch'
5
+ require 'sidekiq/semi_reliable_fetch'
6
+
7
+ describe Sidekiq::BaseReliableFetch do
8
+ before { Sidekiq.redis(&:flushdb) }
9
+
10
+ describe 'UnitOfWork' do
11
+ let(:fetcher) { Sidekiq::ReliableFetch.new(queues: ['foo']) }
12
+
13
+ describe '#requeue' do
14
+ it 'requeues job' do
15
+ Sidekiq.redis { |conn| conn.rpush('queue:foo', 'msg') }
16
+
17
+ uow = fetcher.retrieve_work
18
+
19
+ uow.requeue
20
+
21
+ expect(Sidekiq::Queue.new('foo').size).to eq 1
22
+ expect(working_queue_size('foo')).to eq 0
23
+ end
24
+ end
25
+
26
+ describe '#acknowledge' do
27
+ it 'acknowledges job' do
28
+ Sidekiq.redis { |conn| conn.rpush('queue:foo', 'msg') }
29
+
30
+ uow = fetcher.retrieve_work
31
+
32
+ expect { uow.acknowledge }
33
+ .to change { working_queue_size('foo') }.by(-1)
34
+
35
+ expect(Sidekiq::Queue.new('foo').size).to eq 0
36
+ end
37
+ end
38
+ end
39
+
40
+ describe '.bulk_requeue' do
41
+ it 'requeues the bulk' do
42
+ queue1 = Sidekiq::Queue.new('foo')
43
+ queue2 = Sidekiq::Queue.new('bar')
44
+
45
+ expect(queue1.size).to eq 0
46
+ expect(queue2.size).to eq 0
47
+
48
+ uow = described_class::UnitOfWork
49
+ jobs = [ uow.new('queue:foo', 'bob'), uow.new('queue:foo', 'bar'), uow.new('queue:bar', 'widget') ]
50
+ described_class.bulk_requeue(jobs, queues: [])
51
+
52
+ expect(queue1.size).to eq 2
53
+ expect(queue2.size).to eq 1
54
+ end
55
+ end
56
+
57
+ it 'sets heartbeat' do
58
+ config = double(:sidekiq_config, options: {})
59
+
60
+ heartbeat_thread = described_class.setup_reliable_fetch!(config)
61
+
62
+ Sidekiq.redis do |conn|
63
+ sleep 0.2 # Give the time to heartbeat thread to make a loop
64
+
65
+ heartbeat_key = described_class.heartbeat_key(Socket.gethostname, ::Process.pid)
66
+ heartbeat = conn.get(heartbeat_key)
67
+
68
+ expect(heartbeat).not_to be_nil
69
+ end
70
+
71
+ heartbeat_thread.kill
72
+ end
73
+ end
@@ -0,0 +1,118 @@
1
+ shared_examples 'a Sidekiq fetcher' do
2
+ let(:queues) { ['assigned'] }
3
+
4
+ before { Sidekiq.redis(&:flushdb) }
5
+
6
+ describe '#retrieve_work' do
7
+ let(:fetcher) { described_class.new(queues: ['assigned']) }
8
+
9
+ it 'retrieves the job and puts it to working queue' do
10
+ Sidekiq.redis { |conn| conn.rpush('queue:assigned', 'msg') }
11
+
12
+ uow = fetcher.retrieve_work
13
+
14
+ expect(working_queue_size('assigned')).to eq 1
15
+ expect(uow.queue_name).to eq 'assigned'
16
+ expect(uow.job).to eq 'msg'
17
+ expect(Sidekiq::Queue.new('assigned').size).to eq 0
18
+ end
19
+
20
+ it 'does not retrieve a job from foreign queue' do
21
+ Sidekiq.redis { |conn| conn.rpush('queue:not_assigned', 'msg') }
22
+
23
+ expect(fetcher.retrieve_work).to be_nil
24
+ end
25
+
26
+ it 'requeues jobs from dead working queue' do
27
+ Sidekiq.redis do |conn|
28
+ conn.rpush(other_process_working_queue_name('assigned'), 'msg')
29
+ end
30
+
31
+ uow = fetcher.retrieve_work
32
+
33
+ expect(uow.job).to eq 'msg'
34
+
35
+ Sidekiq.redis do |conn|
36
+ expect(conn.llen(other_process_working_queue_name('assigned'))).to eq 0
37
+ end
38
+ end
39
+
40
+ it 'does not requeue jobs from live working queue' do
41
+ working_queue = live_other_process_working_queue_name('assigned')
42
+
43
+ Sidekiq.redis do |conn|
44
+ conn.rpush(working_queue, 'msg')
45
+ end
46
+
47
+ uow = fetcher.retrieve_work
48
+
49
+ expect(uow).to be_nil
50
+
51
+ Sidekiq.redis do |conn|
52
+ expect(conn.llen(working_queue)).to eq 1
53
+ end
54
+ end
55
+
56
+ it 'does not clean up orphaned jobs more than once per cleanup interval' do
57
+ Sidekiq.redis = Sidekiq::RedisConnection.create(url: REDIS_URL, size: 10)
58
+
59
+ expect_any_instance_of(described_class)
60
+ .to receive(:clean_working_queues!).once
61
+
62
+ threads = 10.times.map do
63
+ Thread.new do
64
+ described_class.new(queues: ['assigned']).retrieve_work
65
+ end
66
+ end
67
+
68
+ threads.map(&:join)
69
+ end
70
+
71
+ it 'retrieves by order when strictly order is enabled' do
72
+ fetcher = described_class.new(strict: true, queues: ['first', 'second'])
73
+
74
+ Sidekiq.redis do |conn|
75
+ conn.rpush('queue:first', ['msg3', 'msg2', 'msg1'])
76
+ conn.rpush('queue:second', 'msg4')
77
+ end
78
+
79
+ jobs = (1..4).map { fetcher.retrieve_work.job }
80
+
81
+ expect(jobs).to eq ['msg1', 'msg2', 'msg3', 'msg4']
82
+ end
83
+
84
+ it 'does not starve any queue when queues are not strictly ordered' do
85
+ fetcher = described_class.new(queues: ['first', 'second'])
86
+
87
+ Sidekiq.redis do |conn|
88
+ conn.rpush('queue:first', (1..200).map { |i| "msg#{i}" })
89
+ conn.rpush('queue:second', 'this_job_should_not_stuck')
90
+ end
91
+
92
+ jobs = (1..100).map { fetcher.retrieve_work.job }
93
+
94
+ expect(jobs).to include 'this_job_should_not_stuck'
95
+ end
96
+ end
97
+ end
98
+
99
+ def working_queue_size(queue_name)
100
+ Sidekiq.redis do |c|
101
+ c.llen(Sidekiq::BaseReliableFetch.working_queue_name("queue:#{queue_name}"))
102
+ end
103
+ end
104
+
105
+ def other_process_working_queue_name(queue)
106
+ "#{Sidekiq::BaseReliableFetch::WORKING_QUEUE_PREFIX}:queue:#{queue}:#{Socket.gethostname}:#{::Process.pid + 1}"
107
+ end
108
+
109
+ def live_other_process_working_queue_name(queue)
110
+ pid = ::Process.pid + 1
111
+ hostname = Socket.gethostname
112
+
113
+ Sidekiq.redis do |conn|
114
+ conn.set(Sidekiq::BaseReliableFetch.heartbeat_key(hostname, pid), 1)
115
+ end
116
+
117
+ "#{Sidekiq::BaseReliableFetch::WORKING_QUEUE_PREFIX}:queue:#{queue}:#{hostname}:#{pid}"
118
+ end
@@ -0,0 +1,7 @@
1
+ require 'spec_helper'
2
+ require 'fetch_shared_examples'
3
+ require 'sidekiq/reliable_fetch'
4
+
5
+ describe Sidekiq::ReliableFetch do
6
+ include_examples 'a Sidekiq fetcher'
7
+ end
@@ -0,0 +1,7 @@
1
+ require 'spec_helper'
2
+ require 'fetch_shared_examples'
3
+ require 'sidekiq/semi_reliable_fetch'
4
+
5
+ describe Sidekiq::SemiReliableFetch do
6
+ include_examples 'a Sidekiq fetcher'
7
+ end
@@ -0,0 +1,115 @@
1
+ require 'sidekiq'
2
+ require 'sidekiq/util'
3
+ require 'sidekiq/api'
4
+ require 'pry'
5
+ require 'simplecov'
6
+
7
+ SimpleCov.start
8
+
9
+ REDIS_URL = ENV['REDIS_URL'] || 'redis://localhost:6379/10'
10
+
11
+ Sidekiq.configure_client do |config|
12
+ config.redis = { url: REDIS_URL }
13
+ end
14
+
15
+ Sidekiq.logger.level = Logger::ERROR
16
+ # This file was generated by the `rspec --init` command. Conventionally, all
17
+ # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`.
18
+ # The generated `.rspec` file contains `--require spec_helper` which will cause
19
+ # this file to always be loaded, without a need to explicitly require it in any
20
+ # files.
21
+ #
22
+ # Given that it is always loaded, you are encouraged to keep this file as
23
+ # light-weight as possible. Requiring heavyweight dependencies from this file
24
+ # will add to the boot time of your test suite on EVERY test run, even for an
25
+ # individual file that may not need all of that loaded. Instead, consider making
26
+ # a separate helper file that requires the additional dependencies and performs
27
+ # the additional setup, and require it from the spec files that actually need
28
+ # it.
29
+ #
30
+ # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration
31
+ RSpec.configure do |config|
32
+ # rspec-expectations config goes here. You can use an alternate
33
+ # assertion/expectation library such as wrong or the stdlib/minitest
34
+ # assertions if you prefer.
35
+ config.expect_with :rspec do |expectations|
36
+ # This option will default to `true` in RSpec 4. It makes the `description`
37
+ # and `failure_message` of custom matchers include text for helper methods
38
+ # defined using `chain`, e.g.:
39
+ # be_bigger_than(2).and_smaller_than(4).description
40
+ # # => "be bigger than 2 and smaller than 4"
41
+ # ...rather than:
42
+ # # => "be bigger than 2"
43
+ expectations.include_chain_clauses_in_custom_matcher_descriptions = true
44
+ end
45
+
46
+ # rspec-mocks config goes here. You can use an alternate test double
47
+ # library (such as bogus or mocha) by changing the `mock_with` option here.
48
+ config.mock_with :rspec do |mocks|
49
+ # Prevents you from mocking or stubbing a method that does not exist on
50
+ # a real object. This is generally recommended, and will default to
51
+ # `true` in RSpec 4.
52
+ mocks.verify_partial_doubles = true
53
+ end
54
+
55
+ # This option will default to `:apply_to_host_groups` in RSpec 4 (and will
56
+ # have no way to turn it off -- the option exists only for backwards
57
+ # compatibility in RSpec 3). It causes shared context metadata to be
58
+ # inherited by the metadata hash of host groups and examples, rather than
59
+ # triggering implicit auto-inclusion in groups with matching metadata.
60
+ config.shared_context_metadata_behavior = :apply_to_host_groups
61
+
62
+ # The settings below are suggested to provide a good initial experience
63
+ # with RSpec, but feel free to customize to your heart's content.
64
+ =begin
65
+ # This allows you to limit a spec run to individual examples or groups
66
+ # you care about by tagging them with `:focus` metadata. When nothing
67
+ # is tagged with `:focus`, all examples get run. RSpec also provides
68
+ # aliases for `it`, `describe`, and `context` that include `:focus`
69
+ # metadata: `fit`, `fdescribe` and `fcontext`, respectively.
70
+ config.filter_run_when_matching :focus
71
+
72
+ # Allows RSpec to persist some state between runs in order to support
73
+ # the `--only-failures` and `--next-failure` CLI options. We recommend
74
+ # you configure your source control system to ignore this file.
75
+ config.example_status_persistence_file_path = "spec/examples.txt"
76
+
77
+ # Limits the available syntax to the non-monkey patched syntax that is
78
+ # recommended. For more details, see:
79
+ # - http://rspec.info/blog/2012/06/rspecs-new-expectation-syntax/
80
+ # - http://www.teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/
81
+ # - http://rspec.info/blog/2014/05/notable-changes-in-rspec-3/#zero-monkey-patching-mode
82
+ config.disable_monkey_patching!
83
+
84
+ # This setting enables warnings. It's recommended, but in some cases may
85
+ # be too noisy due to issues in dependencies.
86
+ config.warnings = true
87
+
88
+ # Many RSpec users commonly either run the entire suite or an individual
89
+ # file, and it's useful to allow more verbose output when running an
90
+ # individual spec file.
91
+ if config.files_to_run.one?
92
+ # Use the documentation formatter for detailed output,
93
+ # unless a formatter has already been configured
94
+ # (e.g. via a command-line flag).
95
+ config.default_formatter = "doc"
96
+ end
97
+
98
+ # Print the 10 slowest examples and example groups at the
99
+ # end of the spec run, to help surface which specs are running
100
+ # particularly slow.
101
+ config.profile_examples = 10
102
+
103
+ # Run specs in random order to surface order dependencies. If you find an
104
+ # order dependency and want to debug it, you can fix the order by providing
105
+ # the seed, which is printed after each run.
106
+ # --seed 1234
107
+ config.order = :random
108
+
109
+ # Seed global randomization in this process using the `--seed` CLI option.
110
+ # Setting this allows you to use `--seed` to deterministically reproduce
111
+ # test failures related to randomization by passing the same `--seed` value
112
+ # as the one that triggered the failure.
113
+ Kernel.srand config.seed
114
+ =end
115
+ end
data/test/README.md ADDED
@@ -0,0 +1,34 @@
1
+ # How to run
2
+
3
+ ```
4
+ cd test
5
+ bundle exec ruby reliability_test.rb
6
+ ```
7
+
8
+ You can adjust some parameters of the test in the `config.rb`
9
+
10
+
11
+ # How it works
12
+
13
+ This tool spawns configured number of Sidekiq workers and when the amount of processed jobs is about half of origin
14
+ number it will kill all the workers with `kill -9` and then it will spawn new workers again until all the jobs are processed. To track the process and counters we use Redis keys/counters.
15
+
16
+ # How to run tests
17
+
18
+ To run rspec:
19
+
20
+ ```
21
+ bundle exec rspec
22
+ ```
23
+
24
+ To run performance tests:
25
+
26
+ ```
27
+ cd test
28
+ JOB_FETCHER=semi bundle exec ruby reliability_test.rb
29
+ ```
30
+
31
+ JOB_FETCHER can be set to one of these values: `semi`, `reliable`, `basic`
32
+
33
+ To run both kind of tests you need to have redis server running on default HTTP port `6379`. To use other HTTP port, you can define
34
+ `REDIS_URL` environment varible with the port you need(example: `REDIS_URL="redis://localhost:9999"`).
data/test/config.rb ADDED
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../lib/sidekiq/base_reliable_fetch'
4
+ require_relative '../lib/sidekiq/reliable_fetch'
5
+ require_relative '../lib/sidekiq/semi_reliable_fetch'
6
+ require_relative 'worker'
7
+
8
+ REDIS_FINISHED_LIST = 'reliable-fetcher-finished-jids'
9
+
10
+ NUMBER_OF_WORKERS = ENV['NUMBER_OF_WORKERS'] || 10
11
+ NUMBER_OF_JOBS = ENV['NUMBER_OF_JOBS'] || 1000
12
+ JOB_FETCHER = (ENV['JOB_FETCHER'] || :semi).to_sym # :basic, :semi, :reliable
13
+ TEST_CLEANUP_INTERVAL = 20
14
+ TEST_LEASE_INTERVAL = 5
15
+ WAIT_CLEANUP = TEST_CLEANUP_INTERVAL +
16
+ TEST_LEASE_INTERVAL +
17
+ Sidekiq::ReliableFetch::HEARTBEAT_LIFESPAN
18
+
19
+ Sidekiq.configure_server do |config|
20
+ if %i[semi reliable].include?(JOB_FETCHER)
21
+ config.options[:semi_reliable_fetch] = (JOB_FETCHER == :semi)
22
+
23
+ # We need to override these parameters to not wait too long
24
+ # The default values are good for production use only
25
+ # These will be ignored for :basic
26
+ config.options[:cleanup_interval] = TEST_CLEANUP_INTERVAL
27
+ config.options[:lease_interval] = TEST_LEASE_INTERVAL
28
+
29
+ Sidekiq::ReliableFetch.setup_reliable_fetch!(config)
30
+ end
31
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sidekiq'
4
+ require 'sidekiq/util'
5
+ require 'sidekiq/cli'
6
+ require_relative 'config'
7
+
8
+ def spawn_workers_and_stop_them_on_a_half_way
9
+ pids = spawn_workers
10
+
11
+ wait_until do |queue_size|
12
+ queue_size < NUMBER_OF_JOBS / 2
13
+ end
14
+
15
+ first_half_pids, second_half_pids = split_array(pids)
16
+
17
+ puts 'Killing half of the workers...'
18
+ signal_to_workers('KILL', first_half_pids)
19
+
20
+ puts 'Stopping another half of the workers...'
21
+ signal_to_workers('TERM', second_half_pids)
22
+ end
23
+
24
+ def spawn_workers_and_let_them_finish
25
+ puts 'Spawn workers and let them finish...'
26
+
27
+ pids = spawn_workers
28
+
29
+ wait_until do |queue_size|
30
+ queue_size.zero?
31
+ end
32
+
33
+ if %i[semi reliable].include? JOB_FETCHER
34
+ puts 'Waiting for clean up process that will requeue dead jobs...'
35
+ sleep WAIT_CLEANUP
36
+ end
37
+
38
+ signal_to_workers('TERM', pids)
39
+ end
40
+
41
+ def wait_until
42
+ loop do
43
+ sleep 3
44
+
45
+ queue_size = current_queue_size
46
+ puts "Jobs in the queue:#{queue_size}"
47
+
48
+ break if yield(queue_size)
49
+ end
50
+ end
51
+
52
+ def signal_to_workers(signal, pids)
53
+ pids.each { |pid| Process.kill(signal, pid) }
54
+ pids.each { |pid| Process.wait(pid) }
55
+ end
56
+
57
+ def spawn_workers
58
+ pids = []
59
+ NUMBER_OF_WORKERS.times do
60
+ pids << spawn('sidekiq -r ./config.rb')
61
+ end
62
+
63
+ pids
64
+ end
65
+
66
+ def current_queue_size
67
+ Sidekiq.redis { |c| c.llen('queue:default') }
68
+ end
69
+
70
+ def duplicates
71
+ Sidekiq.redis { |c| c.llen(REDIS_FINISHED_LIST) }
72
+ end
73
+
74
+ # Splits array into two halves
75
+ def split_array(arr)
76
+ first_arr = arr.take(arr.size / 2)
77
+ second_arr = arr - first_arr
78
+ [first_arr, second_arr]
79
+ end
80
+
81
+ ##########################################################
82
+
83
+ puts '########################################'
84
+ puts "Mode: #{JOB_FETCHER}"
85
+ puts '########################################'
86
+
87
+ Sidekiq.redis(&:flushdb)
88
+
89
+ jobs = []
90
+
91
+ NUMBER_OF_JOBS.times do
92
+ jobs << TestWorker.perform_async
93
+ end
94
+
95
+ puts "Queued #{NUMBER_OF_JOBS} jobs"
96
+
97
+ spawn_workers_and_stop_them_on_a_half_way
98
+ spawn_workers_and_let_them_finish
99
+
100
+ jobs_lost = 0
101
+
102
+ Sidekiq.redis do |redis|
103
+ jobs.each do |job|
104
+ next if redis.lrem(REDIS_FINISHED_LIST, 1, job) == 1
105
+ jobs_lost += 1
106
+ end
107
+ end
108
+
109
+ puts "Remaining unprocessed: #{jobs_lost}"
110
+ puts "Duplicates found: #{duplicates}"
111
+
112
+ if jobs_lost.zero? && duplicates.zero?
113
+ exit 0
114
+ else
115
+ exit 1
116
+ end
data/test/worker.rb ADDED
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ class TestWorker
4
+ include Sidekiq::Worker
5
+
6
+ def perform
7
+ # To mimic long running job and to increase the probability of losing the job
8
+ sleep 1
9
+
10
+ Sidekiq.redis do |redis|
11
+ redis.lpush(REDIS_FINISHED_LIST, get_sidekiq_job_id)
12
+ end
13
+ end
14
+
15
+ def get_sidekiq_job_id
16
+ context_data = Thread.current[:sidekiq_context]&.first
17
+
18
+ return unless context_data
19
+
20
+ index = context_data.index('JID-')
21
+
22
+ return unless index
23
+
24
+ context_data[index + 4..-1]
25
+ end
26
+ end
metadata ADDED
@@ -0,0 +1,80 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: gitlab-sidekiq-fetcher
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - TEA
8
+ - GitLab
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2018-12-14 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: sidekiq
16
+ requirement: !ruby/object:Gem::Requirement
17
+ requirements:
18
+ - - "~>"
19
+ - !ruby/object:Gem::Version
20
+ version: '5'
21
+ type: :runtime
22
+ prerelease: false
23
+ version_requirements: !ruby/object:Gem::Requirement
24
+ requirements:
25
+ - - "~>"
26
+ - !ruby/object:Gem::Version
27
+ version: '5'
28
+ description: Redis reliable queue pattern implemented in Sidekiq
29
+ email: valery@gitlab.com
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - ".gitignore"
35
+ - ".gitlab-ci.yml"
36
+ - ".rspec"
37
+ - Gemfile
38
+ - Gemfile.lock
39
+ - LICENSE
40
+ - README.md
41
+ - RELEASE-GITLAB.md
42
+ - gitlab-sidekiq-fetcher.gemspec
43
+ - lib/sidekiq-reliable-fetch.rb
44
+ - lib/sidekiq/base_reliable_fetch.rb
45
+ - lib/sidekiq/reliable_fetch.rb
46
+ - lib/sidekiq/semi_reliable_fetch.rb
47
+ - spec/base_reliable_fetch_spec.rb
48
+ - spec/fetch_shared_examples.rb
49
+ - spec/reliable_fetch_spec.rb
50
+ - spec/semi_reliable_fetch_spec.rb
51
+ - spec/spec_helper.rb
52
+ - test/README.md
53
+ - test/config.rb
54
+ - test/reliability_test.rb
55
+ - test/worker.rb
56
+ homepage: https://gitlab.com/gitlab-org/sidekiq-reliable-fetch/
57
+ licenses:
58
+ - LGPL-3.0
59
+ metadata: {}
60
+ post_install_message:
61
+ rdoc_options: []
62
+ require_paths:
63
+ - lib
64
+ required_ruby_version: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ required_rubygems_version: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - ">="
72
+ - !ruby/object:Gem::Version
73
+ version: '0'
74
+ requirements: []
75
+ rubyforge_project:
76
+ rubygems_version: 2.7.6
77
+ signing_key:
78
+ specification_version: 4
79
+ summary: Reliable fetch extension for Sidekiq
80
+ test_files: []