gitlab-sidekiq-fetcher 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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: []