atomic-sidekiq 1.0.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
+ SHA1:
3
+ metadata.gz: 1af076c37beeb362724591b736eca75821223033
4
+ data.tar.gz: a8ffb7fcfafa8ee3f70e24e13b728703ad55e4c8
5
+ SHA512:
6
+ metadata.gz: 6ae93a470419fac19638dd02a0f92f7f6972a9129ac11fba26265062a3ab186dfb1962ed4718fdce84cf2a2c1ef0ecae07d0fb6df79d6316d78224cd33d6b755
7
+ data.tar.gz: 4ca5974dc84bd4d8fa1b8efbbf4bb5a862022039cc8336b149ba0287665324d5565846da977b21eaf1fd9574d666f03f455f15643d7713fa5168018946a53004
data/.gitignore ADDED
@@ -0,0 +1 @@
1
+ .byebug_history
data/.rspec ADDED
@@ -0,0 +1 @@
1
+ --require spec_helper -I ./lib
data/Dockerfile ADDED
@@ -0,0 +1,22 @@
1
+ # Choose the official Ruby 2.3.4 image as our starting point
2
+ FROM ruby:2.5.1
3
+
4
+ ENV LC_ALL en_US.UTF-8
5
+
6
+ # install locked bundler version (1.16.1)
7
+ RUN gem install bundler -v 1.16.1
8
+ ENV BUNDLE_PATH=/bundle BUNDLE_JOBS=4
9
+
10
+ # Set up working directory
11
+ ENV APP_HOME /sidekiq-atomic
12
+
13
+ RUN mkdir $APP_HOME
14
+
15
+ WORKDIR $APP_HOME
16
+
17
+ ADD Gemfile .
18
+ ADD Gemfile.lock .
19
+
20
+ RUN bundle install
21
+
22
+ ADD . $APP_HOME
data/Gemfile ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gemspec
data/Gemfile.lock ADDED
@@ -0,0 +1,67 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ atomic-sidekiq (0.0.0)
5
+ sidekiq (~> 5.0)
6
+
7
+ GEM
8
+ remote: https://rubygems.org/
9
+ specs:
10
+ ast (2.4.0)
11
+ byebug (10.0.2)
12
+ concurrent-ruby (1.0.5)
13
+ connection_pool (2.2.1)
14
+ diff-lcs (1.3)
15
+ parallel (1.12.1)
16
+ parser (2.5.0.5)
17
+ ast (~> 2.4.0)
18
+ powerpack (0.1.1)
19
+ rack (2.0.4)
20
+ rack-protection (2.0.1)
21
+ rack
22
+ rainbow (3.0.0)
23
+ rake (11.3.0)
24
+ redis (4.0.1)
25
+ rspec (3.7.0)
26
+ rspec-core (~> 3.7.0)
27
+ rspec-expectations (~> 3.7.0)
28
+ rspec-mocks (~> 3.7.0)
29
+ rspec-core (3.7.1)
30
+ rspec-support (~> 3.7.0)
31
+ rspec-expectations (3.7.0)
32
+ diff-lcs (>= 1.2.0, < 2.0)
33
+ rspec-support (~> 3.7.0)
34
+ rspec-mocks (3.7.0)
35
+ diff-lcs (>= 1.2.0, < 2.0)
36
+ rspec-support (~> 3.7.0)
37
+ rspec-support (3.7.1)
38
+ rubocop (0.54.0)
39
+ parallel (~> 1.10)
40
+ parser (>= 2.5)
41
+ powerpack (~> 0.1)
42
+ rainbow (>= 2.2.2, < 4.0)
43
+ ruby-progressbar (~> 1.7)
44
+ unicode-display_width (~> 1.0, >= 1.0.1)
45
+ ruby-progressbar (1.9.0)
46
+ sidekiq (5.1.2)
47
+ concurrent-ruby (~> 1.0)
48
+ connection_pool (~> 2.2, >= 2.2.0)
49
+ rack-protection (>= 1.5.0)
50
+ redis (>= 3.3.5, < 5)
51
+ timecop (0.9.1)
52
+ unicode-display_width (1.3.0)
53
+
54
+ PLATFORMS
55
+ ruby
56
+
57
+ DEPENDENCIES
58
+ atomic-sidekiq!
59
+ bundler (~> 1.12)
60
+ byebug (~> 10.0)
61
+ rake (~> 11.3)
62
+ rspec (~> 3.6)
63
+ rubocop (~> 0.54)
64
+ timecop (~> 0.9)
65
+
66
+ BUNDLED WITH
67
+ 1.16.1
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License
2
+
3
+ Copyright (c) 2010-2018 Google, Inc. http://angularjs.org
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,68 @@
1
+ # AtomicSidekiq
2
+ AtomicSidekiq implements a reliable way of processing jobs using Sidekiq. By default, Sidekiq will retrieve jobs from the queue by removing it from Redis. If the job fails to complete (e.g. the process terminates unexpectdly mid-job), the job will be lost forever. This can be acceptable in many applications, but some application require higher levels of reliability, hence AtomicSidekiq will not erase any job from Redis until it's acknowledged that they have finished - otherwise, they are re-scheduled.
3
+
4
+ The algorithm used by AtomicSidekiq supports both queue prioritization mechanisms: strict priority and weighted random.
5
+
6
+ ## Requirements
7
+ AtomicSidekiq supports only Sidekiq 5+.
8
+
9
+ ## Installation
10
+ ```
11
+ gem install atomic-sidekiq
12
+ ```
13
+
14
+ Add to your server configuration (or create a new one if you don't have):
15
+ ```ruby
16
+ Sidekiq.configure_server do |config|
17
+ config.atomic_fetch!
18
+ end
19
+ ```
20
+
21
+ ## Configuration
22
+ By default, jobs will expire and be re-queued after 1 hour if not acknowledged, and the "Collector" will check if for expired jobs every 60 seconds. This can be reconfigured as desired: _(Note that collection adds some overhead)_
23
+ ```ruby
24
+ Sidekiq.configure_server do |config|
25
+ config.atomic_fetch!({
26
+ collection_interval: 5, # Unit: seconds
27
+ expiration_time: 1800 # Unit: seconds (30 minutes)
28
+ })
29
+ end
30
+ ```
31
+
32
+ ## Benchmark
33
+ ### Reliability
34
+ This benchmark tests Sidekiq's ability to recover from unexpected failures. The test script forces a failure randomly 1% of the time it's running a job and measures how many jobs are able to be completed:
35
+
36
+ | Version | Queued | Processed | Lost |
37
+ |---------------|---------|-----------|-------|
38
+ | Sidekiq | 10,000 | 1,838 | 8,162 |
39
+ | AtomicSidekiq | 10,000 | 10,000 | 0 |
40
+
41
+ Since jobs run in parallel, when the process crashes it loses all jobs that had been retrieved and were running at the moment. AtomicSidekiq manage to retrieve all jobs and finish the work. The reliability script can be found at `./bin/sidekiqfail`. It terminates and restores Sidekiq several times until all jobs are processed or a maximum number of tries is reached.
42
+
43
+ The test script can be run with the flag `-a` to use the **AtomicSidekiq::AtomicFetch** and without any flags to run with the default Sidekiq fetcher.
44
+
45
+ _(Note: Sidekiq PRO comes with its own reliable fetcher, no benchmarks were run against that version. Only the free version has been tested)_
46
+
47
+ ### Performance
48
+ The performance test uses the default settings for both fetchers (default and AtomicFetch) and times how long Sidekiq takes to process two loads, one of 10,000 and another one of 100,000 jobs.
49
+
50
+ | Version | Time Ellapsed (10k) | Throughput (10k) | Time Ellapsed (100k) | Throughput (100k) |
51
+ |---------------|---------------------|------------------|----------------------|-------------------|
52
+ | Sidekiq | 6s | 166 jobs/sec | 30s | 3,333 jobs/sec |
53
+ | AtomicSidekiq | 8s | 125 jobs/sec | 1m10s | 1,429 jobs/sec |
54
+
55
+ The reliability improvements of AtomicSidekiq come at the cost of less throughput. AtomicSidekiq's algorithm is linear instead of constant like Sidekiq's default, meaning that the cost of performance increases linearly as more jobs are added to the queue.
56
+
57
+ ## Tests
58
+ ```sh
59
+ bundle exec rspec
60
+ ```
61
+
62
+ You may also run the tests with Docker using `docker-compose` (it will automatially start a Redis server for the integration tests):
63
+ ```sh
64
+ docker-compose run test
65
+ ```
66
+
67
+ ## Caveat
68
+ This ensures that your job will be run completely **at least once**. It may run more than once if your job fails to acknowledge (e.g. the process terminates after performing a job but right before the ack is sent). _Note: This is better than the default Sidekiq though, which cannot give any guarantees on the number of times a job will be run._
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ Gem::Specification.new do |s|
4
+ s.name = "atomic-sidekiq"
5
+ s.version = "1.0.0"
6
+ s.date = "2018-04-01"
7
+ s.summary = "Reliable fetcher for Sidekiq"
8
+ s.description = "Reliable fetcher for Sidekiq"
9
+ s.homepage = "https://github.com/Colex/atomic-sidekiq"
10
+ s.authors = ["Alex Correia Santos"]
11
+ s.email = ["alex.santios@visiblealpha.com"]
12
+ s.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
13
+ s.require_paths = ["lib"]
14
+ s.license = "MIT"
15
+
16
+ s.required_ruby_version = ">= 2.3"
17
+
18
+ s.add_development_dependency "bundler", "~> 1.12"
19
+ s.add_development_dependency "rake", "~> 11.3"
20
+ s.add_development_dependency "rspec", "~> 3.6"
21
+ s.add_development_dependency "rubocop", "~> 0.54"
22
+ s.add_development_dependency "byebug", "~> 10.0"
23
+ s.add_development_dependency "timecop", "~> 0.9"
24
+
25
+ s.add_runtime_dependency "sidekiq", "~> 5.0"
26
+ end
data/bin/release ADDED
@@ -0,0 +1,9 @@
1
+ #!/bin/bash
2
+ cp atomic-sidekiq.gemspec atomic-sidekiq.gemspec.backup
3
+ sed -i.bak s/0.0.0/$1/g atomic-sidekiq.gemspec
4
+ sed -i.bak s/2001-01-01/`date +%Y-%m-%d`/g atomic-sidekiq.gemspec
5
+ gem build atomic-sidekiq.gemspec
6
+ mv atomic-sidekiq.gemspec.backup atomic-sidekiq.gemspec
7
+ gem push atomic-sidekiq-$1.gem
8
+ git tag -a v$1 -m "Release v$1"
9
+ git push origin --tags
data/bin/sidekiqfail ADDED
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'optparse'
4
+ require 'sidekiq'
5
+
6
+ max_tries = 10
7
+ cutoff_tries = 100
8
+
9
+ options = {
10
+ atomic_fetch: false
11
+ }
12
+ OptionParser.new do |opts|
13
+ opts.banner = "Usage: sidekiqfail [options]"
14
+
15
+ opts.on("-a", "--atomic-fetch", "Run the Sidekiq worker with the atomic-fetch fetcher") do |v|
16
+ options[:atomic_fetch] = v
17
+ end
18
+ end.parse!
19
+
20
+ Sidekiq.configure_client do |config|
21
+ config.redis = { db: 13 }
22
+ end
23
+
24
+ def processed
25
+ Sidekiq.redis { |conn| conn.get('done') }.to_i
26
+ end
27
+
28
+ def total
29
+ Sidekiq.redis { |conn| conn.get('total_enqueued') }.to_i
30
+ end
31
+
32
+ def inflight
33
+ counter = it = 0
34
+ loop do
35
+ it, keys = Sidekiq.redis { |c| c.scan(it, match: 'flight:*') }
36
+ counter += keys.count
37
+ it = it.to_i
38
+ break if it == 0
39
+ end
40
+ counter
41
+ end
42
+
43
+ def pending
44
+ total - processed
45
+ end
46
+
47
+ def print_report
48
+ Sidekiq.logger.error "Queued: #{total}"
49
+ Sidekiq.logger.error "Processed: #{processed}"
50
+ Sidekiq.logger.error "Lost: #{pending}"
51
+ end
52
+
53
+ args = ['-f', 'true', '-b', '10', '-j', '1000', '-t', '0.01']
54
+ args.push('-a') if options[:atomic_fetch]
55
+ pid = Process.fork { Process.exec('./bin/sidekiqload', *args) }
56
+ loop do
57
+ Process.wait(pid)
58
+ puts "Processed before failure #{processed} out of #{total}"
59
+ break if pending == 0 || max_tries == 0 || cutoff_tries == 0
60
+ max_tries -= 1 if inflight == 0
61
+ cutoff_tries -= 1
62
+ args = ['-f', 'false', '-b', '0', '-j', '0', '-t', '0.01']
63
+ args.push('-a') if options[:atomic_fetch]
64
+ pid = Process.fork { Process.exec('./bin/sidekiqload', *args) }
65
+ end
66
+ print_report
data/bin/sidekiqload ADDED
@@ -0,0 +1,227 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # Quiet some warnings we see when running in warning mode:
4
+ # RUBYOPT=-w bundle exec sidekiq
5
+ $TESTING = false
6
+
7
+ #require 'ruby-prof'
8
+ Bundler.require(:default)
9
+
10
+ require 'sidekiq/cli'
11
+ require 'sidekiq/launcher'
12
+ require 'optparse'
13
+ require_relative '../lib/atomic-sidekiq'
14
+
15
+ $options = {
16
+ batches: 10,
17
+ jobs: 1_000,
18
+ terminate: 0,
19
+ flush: true,
20
+ expiration: 2,
21
+ atomic_fetch: false
22
+ }
23
+
24
+ OptionParser.new do |opts|
25
+ opts.banner = "Usage: sidekiqload [options]"
26
+
27
+ opts.on("-b n", "--batches=n", "Number of job batches to be created [default 10]") do |v|
28
+ $options[:batches] = v.to_i
29
+ end
30
+ opts.on("-j n", "--jobs=n", "Number of jobs in each batch [default 1000]") do |v|
31
+ $options[:jobs] = v.to_i
32
+ end
33
+ opts.on("-t n", "--terminate=n", "Probability of terminating the thread [default 0]") do |v|
34
+ $options[:terminate] = v.to_f
35
+ end
36
+ opts.on("-f n", "--flush=n", "Flush all jobs that have been created before [default true]") do |v|
37
+ $options[:flush] = (v == 'true')
38
+ end
39
+ opts.on("-e n", "--expiration=n", "Expiration time in seconds for a job [default 2]") do |v|
40
+ $options[:expiration] = v.to_i
41
+ end
42
+ opts.on("-a", "--atomic-fetch", "Run the Sidekiq worker with the atomic-fetch fetcher") do |v|
43
+ $options[:atomic_fetch] = v
44
+ end
45
+ end.parse!
46
+
47
+ include Sidekiq::Util
48
+
49
+ Sidekiq.configure_server do |config|
50
+ #config.options[:concurrency] = 1
51
+ config.redis = { db: 13 }
52
+ config.options[:queues] << 'default'
53
+ config.logger.level = Logger::ERROR
54
+ config.average_scheduled_poll_interval = 2
55
+ config.atomic_fetch!({
56
+ collection_interval: 10,
57
+ expiration_time: $options[:expiration],
58
+ }) if $options[:atomic_fetch]
59
+ end
60
+
61
+ class LoadWorker
62
+ include Sidekiq::Worker
63
+ sidekiq_options retry: 1
64
+ sidekiq_retry_in do |x|
65
+ 1
66
+ end
67
+
68
+ def perform(batch, idx)
69
+ if rand < $options[:terminate]
70
+ Sidekiq.logger.error("Terminating on job #{idx}")
71
+ Process.kill("KILL", Process.pid)
72
+ end
73
+ begin
74
+ Sidekiq.redis do |conn|
75
+ conn.eval("""
76
+ local lock = redis.call('get', 'jobs:#{batch}:#{idx}')
77
+ if lock then return nil end
78
+ redis.call('set', 'jobs:#{batch}:#{idx}', '1')
79
+ redis.call('incr', 'done')
80
+ return nil
81
+ """)
82
+ end
83
+ rescue e
84
+ Sidekiq.logger.error(e)
85
+ end
86
+ #raise idx.to_s if idx % 100 == 1
87
+ end
88
+ end
89
+
90
+ # brew tap shopify/shopify
91
+ # brew install toxiproxy
92
+ # gem install toxiproxy
93
+ #require 'toxiproxy'
94
+ # simulate a non-localhost network for realer-world conditions.
95
+ # adding 1ms of network latency has an ENORMOUS impact on benchmarks
96
+ #Toxiproxy.populate([{
97
+ #"name": "redis",
98
+ #"listen": "127.0.0.1:6380",
99
+ #"upstream": "127.0.0.1:6379"
100
+ #}])
101
+
102
+ self_read, self_write = IO.pipe
103
+ %w(INT TERM TSTP TTIN).each do |sig|
104
+ begin
105
+ trap sig do
106
+ puts("Killed with #{sig}")
107
+ self_write.puts(sig)
108
+ end
109
+ rescue ArgumentError
110
+ puts "Signal #{sig} not supported"
111
+ end
112
+ end
113
+
114
+ if ($options[:flush])
115
+ puts "Flushing database..."
116
+ Sidekiq.redis {|c| c.flushdb }
117
+ end
118
+
119
+ def handle_signal(launcher, sig)
120
+ Sidekiq.logger.debug "Got #{sig} signal"
121
+ case sig
122
+ when 'INT'
123
+ # Handle Ctrl-C in JRuby like MRI
124
+ # http://jira.codehaus.org/browse/JRUBY-4637
125
+ raise Interrupt
126
+ when 'TERM'
127
+ # Heroku sends TERM and then waits 10 seconds for process to exit.
128
+ raise Interrupt
129
+ when 'TSTP'
130
+ Sidekiq.logger.info "Received TSTP, no longer accepting new work"
131
+ launcher.quiet
132
+ when 'TTIN'
133
+ Thread.list.each do |thread|
134
+ Sidekiq.logger.warn "Thread TID-#{(thread.object_id ^ ::Process.pid).to_s(36)} #{thread['label']}"
135
+ if thread.backtrace
136
+ Sidekiq.logger.warn thread.backtrace.join("\n")
137
+ else
138
+ Sidekiq.logger.warn "<no backtrace available>"
139
+ end
140
+ end
141
+ end
142
+ end
143
+
144
+ def Process.rss
145
+ `ps -o rss= -p #{Process.pid}`.chomp.to_i
146
+ end
147
+
148
+ iter = $options[:batches]
149
+ count = $options[:jobs]
150
+
151
+ iter.times do |batch|
152
+ arr = Array.new(count) do
153
+ []
154
+ end
155
+ count.times do |idx|
156
+ arr[idx][0] = idx
157
+ arr[idx][1] = batch
158
+ end
159
+ Sidekiq::Client.push_bulk('class' => LoadWorker, 'args' => arr)
160
+ end
161
+ total_enqueued = Sidekiq.redis { |conn| conn.get('total_enqueued') }.to_i + count * iter
162
+ Sidekiq.redis { |conn| conn.set('total_enqueued', total_enqueued) }
163
+ Sidekiq.logger.error "Created #{count*iter} jobs (total: #{total_enqueued})"
164
+
165
+ Monitoring = Thread.new do
166
+ def total
167
+ qsize, retries = Sidekiq.redis do |conn|
168
+ conn.pipelined do
169
+ conn.llen "queue:default"
170
+ conn.zcard "retry"
171
+ end
172
+ end.map(&:to_i)
173
+ qsize + retries
174
+ end
175
+
176
+ def inflight
177
+ counter = it = 0
178
+ loop do
179
+ it, keys = Sidekiq.redis { |c| c.scan(it, match: 'flight:*') }
180
+ counter += keys.count
181
+ it = it.to_i
182
+ break if it == 0
183
+ end
184
+ counter
185
+ end
186
+
187
+ watchdog("monitor thread") do
188
+ while true
189
+ sleep 1
190
+ #GC.start
191
+ _total = total
192
+ _inflight = inflight
193
+ Sidekiq.logger.error("RSS: #{Process.rss} Pending: #{_total} Inflight: #{_inflight}")
194
+ if _total == 0 && _inflight == 0
195
+ Sidekiq.logger.error("Done")
196
+ exit(0)
197
+ end
198
+ end
199
+ end
200
+ end
201
+
202
+ begin
203
+ # RubyProf::exclude_threads = [ Monitoring ]
204
+ #RubyProf.start
205
+ fire_event(:startup)
206
+ #Sidekiq.logger.error "Simulating 1ms of latency between Sidekiq and redis"
207
+ #Toxiproxy[:redis].downstream(:latency, latency: 1).apply do
208
+ launcher = Sidekiq::Launcher.new(Sidekiq.options)
209
+ launcher.run
210
+
211
+ while readable_io = IO.select([self_read])
212
+ signal = readable_io.first[0].gets.strip
213
+ handle_signal(launcher, signal)
214
+ end
215
+ #end
216
+ rescue SystemExit => e
217
+ # Sidekiq.logger.error("Profiling...")
218
+ #result = RubyProf.stop
219
+ #printer = RubyProf::GraphHtmlPrinter.new(result)
220
+ #printer.print(File.new("output.html", "w"), :min_percent => 1)
221
+ # normal
222
+ rescue => e
223
+ raise e if $DEBUG
224
+ STDERR.puts e.message
225
+ STDERR.puts e.backtrace.join("\n")
226
+ exit 1
227
+ end
data/bin/test ADDED
@@ -0,0 +1,6 @@
1
+ #!/bin/bash
2
+
3
+ set -e
4
+
5
+ bundle check || bundle install
6
+ bundle exec rspec
@@ -0,0 +1,31 @@
1
+ version: '2'
2
+
3
+ services:
4
+ test:
5
+ build: .
6
+ command: bin/test
7
+ env_file:
8
+ - docker.env
9
+ depends_on:
10
+ - redis
11
+ volumes:
12
+ - .:/sidekiq-atomic
13
+ image: sidekiq-atomic
14
+ volumes_from:
15
+ - bundle
16
+
17
+ redis:
18
+ image: redis:3.2-alpine
19
+ ports:
20
+ - 6379:6379
21
+ volumes:
22
+ - redis:/var/lib/redis/data
23
+
24
+ bundle:
25
+ image: tianon/true
26
+ volumes:
27
+ - bundle:/bundle
28
+
29
+ volumes:
30
+ redis:
31
+ bundle:
data/docker.env ADDED
@@ -0,0 +1 @@
1
+ REDIS_URL=redis://redis:6379
@@ -0,0 +1 @@
1
+ require_relative './atomic_sidekiq'
@@ -0,0 +1,55 @@
1
+ module AtomicSidekiq
2
+ class AtomicFetch
3
+ IN_FLIGHT_KEY_PREFIX = "flight:"
4
+ DEFAULT_POLL_INTERVAL = 10 # seconds
5
+ DEFAULT_EXPIRATION_TIME = 3600 # seconds
6
+ DEFAULT_COLLECTION_INTERVAL = 60 # seconds
7
+
8
+ def initialize(options)
9
+ @retrieve_op = AtomicOperation::Retrieve.new(in_flight_prefix: IN_FLIGHT_KEY_PREFIX)
10
+ @strictly_ordered_queues = !!options[:strict]
11
+
12
+ atomic_fetch_opts = options.fetch(:atomic_fetch, {})
13
+ @expiration_time = atomic_fetch_opts.fetch(:expiration_time, DEFAULT_EXPIRATION_TIME)
14
+ @collection_interval = atomic_fetch_opts.fetch(:collection_wait_time, DEFAULT_COLLECTION_INTERVAL)
15
+ @poll_interval = atomic_fetch_opts.fetch(:poll_interval, DEFAULT_POLL_INTERVAL)
16
+ @@next_collection ||= Time.now
17
+ set_queues(options)
18
+ end
19
+
20
+ def retrieve_work
21
+ collect_dead_jobs!
22
+ work = retrieve_op.perform(ordered_queues, expire_at)
23
+ return UnitOfWork.new(*work) if work
24
+ sleep(poll_interval)
25
+ nil
26
+ end
27
+
28
+ private
29
+
30
+ attr_reader :retrieve_op, :queues, :strictly_ordered_queues,
31
+ :collection_interval, :poll_interval, :expiration_time
32
+
33
+ def set_queues(options)
34
+ @queues ||= options[:queues].map { |q| "queue:#{q}" }
35
+ end
36
+
37
+ def ordered_queues
38
+ if strictly_ordered_queues
39
+ queues
40
+ else
41
+ queues.shuffle.uniq
42
+ end
43
+ end
44
+
45
+ def collect_dead_jobs!
46
+ return if @@next_collection > Time.now
47
+ @@next_collection = Time.now + collection_interval
48
+ DeadJobCollector.collect!(ordered_queues)
49
+ end
50
+
51
+ def expire_at
52
+ Time.now.utc.to_i + expiration_time
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,11 @@
1
+ module AtomicSidekiq
2
+ module AtomicOperation
3
+ class Acknowledge < Base
4
+ def perform(queue:, job:)
5
+ redis do |conn|
6
+ conn.del(in_flight_job_key(queue, job))
7
+ end
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,22 @@
1
+ module AtomicSidekiq
2
+ module AtomicOperation
3
+ class Base
4
+ def initialize(in_flight_prefix:)
5
+ @in_flight_prefix = in_flight_prefix
6
+ end
7
+
8
+ protected
9
+
10
+ attr_reader :in_flight_prefix
11
+
12
+ def redis(&block)
13
+ Sidekiq.redis { |conn| block.call(conn) }
14
+ end
15
+
16
+ def in_flight_job_key(queue, job)
17
+ jid = JSON.parse(job)['jid']
18
+ "#{in_flight_prefix}#{queue}:#{jid}"
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,19 @@
1
+ module AtomicSidekiq
2
+ module AtomicOperation
3
+ class Expire < Base
4
+ def initialize
5
+ super(in_flight_prefix: nil)
6
+ end
7
+
8
+ def perform(queue, in_flight_key)
9
+ redis do |conn|
10
+ conn.eval(EXPIRE_SCRIPT, [queue, in_flight_key], [Time.now.utc.to_i])
11
+ end
12
+ end
13
+
14
+ private
15
+
16
+ EXPIRE_SCRIPT = File.read(File.join(File.dirname(__FILE__), './lua_scripts/expire.lua'))
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,15 @@
1
+ local queue = KEYS[1]
2
+ local in_flight_key = KEYS[2]
3
+ local now = tonumber(ARGV[1])
4
+
5
+ local job = redis.call('get', in_flight_key)
6
+ if (not job) then return nil end
7
+
8
+ local expiration = tonumber(string.match(job, '"expire_at":([0-9]*)'))
9
+ if expiration > now then return nil end
10
+ job = string.gsub(job, ',?"expire_at":[0-9]*', '')
11
+
12
+ redis.call('lpush', queue, job)
13
+ redis.call('del', in_flight_key)
14
+
15
+ return { queue, job }
@@ -0,0 +1,11 @@
1
+ local queue = KEYS[1]
2
+ local flight = KEYS[2]
3
+ local expire_at = tonumber(ARGV[1])
4
+
5
+ local job = redis.call('lpop', queue)
6
+ if (not job) then return nil end
7
+ job = job:sub(1,-2)..',"expire_at":'..expire_at.."}"
8
+
9
+ local flight_key = flight..queue..':'..string.match(job, '"jid":"([^"]*)"')
10
+ redis.call('set', flight_key, job)
11
+ return { queue, job }
@@ -0,0 +1,20 @@
1
+ module AtomicSidekiq
2
+ module AtomicOperation
3
+ class Requeue < Base
4
+ def perform(queue:, job:)
5
+ redis do |conn|
6
+ requeue(conn, queue: queue, job: job)
7
+ end
8
+ end
9
+
10
+ private
11
+
12
+ def requeue(conn, queue:, job:)
13
+ conn.multi do
14
+ conn.rpush(queue, job)
15
+ conn.del(in_flight_job_key(queue, job))
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,23 @@
1
+ module AtomicSidekiq
2
+ module AtomicOperation
3
+ class Retrieve < Base
4
+ def perform(queues, expire_at)
5
+ queues.each do |queue|
6
+ res = retrieve_from_queue(queue, expire_at.to_i)
7
+ return res if res
8
+ end
9
+ nil
10
+ end
11
+
12
+ private
13
+
14
+ RETRIEVE_SCRIPT = File.read(File.join(File.dirname(__FILE__), './lua_scripts/retrieve.lua'))
15
+
16
+ def retrieve_from_queue(queue, expire_at)
17
+ redis do |conn|
18
+ conn.eval(RETRIEVE_SCRIPT, [queue, in_flight_prefix], [expire_at])
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,43 @@
1
+ module AtomicSidekiq
2
+ class DeadJobCollector
3
+ class << self
4
+ def collect!(queues)
5
+ queues.each { |q| new(q).collect! }
6
+ end
7
+ end
8
+
9
+ def initialize(queue, in_flight_prefix: AtomicFetch::IN_FLIGHT_KEY_PREFIX)
10
+ @queue = queue
11
+ @in_flight_prefix = in_flight_prefix
12
+ @expire_op = AtomicOperation::Expire.new
13
+ end
14
+
15
+ def collect!
16
+ each_keys { |job_key| expire!(job_key) }
17
+ end
18
+
19
+ private
20
+
21
+ attr_reader :queue, :in_flight_prefix, :expire_op
22
+
23
+ def expire!(job_key)
24
+ expire_op.perform(queue, job_key)
25
+ end
26
+
27
+ def each_keys(&block)
28
+ it = 0
29
+ Sidekiq.redis do |conn|
30
+ loop do
31
+ it, job_keys = conn.scan(it, match: keys_prefix)
32
+ it = it.to_i
33
+ job_keys.each { |job_key| block.call(job_key) }
34
+ break if it == 0
35
+ end
36
+ end
37
+ end
38
+
39
+ def keys_prefix
40
+ "#{in_flight_prefix}#{queue}:*"
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,6 @@
1
+ module Sidekiq
2
+ def self.atomic_fetch!(opts = {})
3
+ self.options[:fetch] = AtomicSidekiq::AtomicFetch
4
+ self.options[:atomic_fetch] = opts
5
+ end
6
+ end
@@ -0,0 +1,28 @@
1
+ module AtomicSidekiq
2
+ class UnitOfWork
3
+ attr_reader :queue, :job
4
+
5
+ def initialize(queue = nil, job = nil, in_flight_prefix: AtomicFetch::IN_FLIGHT_KEY_PREFIX)
6
+ @queue = queue
7
+ @job = job
8
+ @acknowledge_op = AtomicOperation::Acknowledge.new(in_flight_prefix: in_flight_prefix)
9
+ @requeue_op = AtomicOperation::Requeue.new(in_flight_prefix: in_flight_prefix)
10
+ end
11
+
12
+ def acknowledge
13
+ acknowledge_op.perform(queue: queue, job: job)
14
+ end
15
+
16
+ def queue_name
17
+ "queue:#{queue.sub(/.*queue:/, '')}"
18
+ end
19
+
20
+ def requeue
21
+ requeue_op.perform(queue: queue, job: job)
22
+ end
23
+
24
+ private
25
+
26
+ attr_reader :acknowledge_op, :requeue_op
27
+ end
28
+ end
@@ -0,0 +1,13 @@
1
+ require 'sidekiq'
2
+ require_relative 'atomic_sidekiq/sidekiq'
3
+ require_relative 'atomic_sidekiq/unit_of_work'
4
+ require_relative 'atomic_sidekiq/atomic_fetch'
5
+ require_relative 'atomic_sidekiq/dead_job_collector'
6
+ require_relative 'atomic_sidekiq/atomic_operation/base'
7
+ require_relative 'atomic_sidekiq/atomic_operation/acknowledge'
8
+ require_relative 'atomic_sidekiq/atomic_operation/requeue'
9
+ require_relative 'atomic_sidekiq/atomic_operation/retrieve'
10
+ require_relative 'atomic_sidekiq/atomic_operation/expire'
11
+
12
+ module AtomicSidekiq
13
+ end
metadata ADDED
@@ -0,0 +1,169 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: atomic-sidekiq
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Alex Correia Santos
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2018-04-01 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bundler
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '1.12'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '1.12'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rake
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '11.3'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '11.3'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3.6'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.6'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rubocop
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '0.54'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '0.54'
69
+ - !ruby/object:Gem::Dependency
70
+ name: byebug
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '10.0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '10.0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: timecop
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '0.9'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '0.9'
97
+ - !ruby/object:Gem::Dependency
98
+ name: sidekiq
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '5.0'
104
+ type: :runtime
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '5.0'
111
+ description: Reliable fetcher for Sidekiq
112
+ email:
113
+ - alex.santios@visiblealpha.com
114
+ executables: []
115
+ extensions: []
116
+ extra_rdoc_files: []
117
+ files:
118
+ - ".gitignore"
119
+ - ".rspec"
120
+ - Dockerfile
121
+ - Gemfile
122
+ - Gemfile.lock
123
+ - LICENSE
124
+ - README.md
125
+ - atomic-sidekiq.gemspec
126
+ - bin/release
127
+ - bin/sidekiqfail
128
+ - bin/sidekiqload
129
+ - bin/test
130
+ - docker-compose.yml
131
+ - docker.env
132
+ - lib/atomic-sidekiq.rb
133
+ - lib/atomic_sidekiq.rb
134
+ - lib/atomic_sidekiq/atomic_fetch.rb
135
+ - lib/atomic_sidekiq/atomic_operation/acknowledge.rb
136
+ - lib/atomic_sidekiq/atomic_operation/base.rb
137
+ - lib/atomic_sidekiq/atomic_operation/expire.rb
138
+ - lib/atomic_sidekiq/atomic_operation/lua_scripts/expire.lua
139
+ - lib/atomic_sidekiq/atomic_operation/lua_scripts/retrieve.lua
140
+ - lib/atomic_sidekiq/atomic_operation/requeue.rb
141
+ - lib/atomic_sidekiq/atomic_operation/retrieve.rb
142
+ - lib/atomic_sidekiq/dead_job_collector.rb
143
+ - lib/atomic_sidekiq/sidekiq.rb
144
+ - lib/atomic_sidekiq/unit_of_work.rb
145
+ homepage: https://github.com/Colex/atomic-sidekiq
146
+ licenses:
147
+ - MIT
148
+ metadata: {}
149
+ post_install_message:
150
+ rdoc_options: []
151
+ require_paths:
152
+ - lib
153
+ required_ruby_version: !ruby/object:Gem::Requirement
154
+ requirements:
155
+ - - ">="
156
+ - !ruby/object:Gem::Version
157
+ version: '2.3'
158
+ required_rubygems_version: !ruby/object:Gem::Requirement
159
+ requirements:
160
+ - - ">="
161
+ - !ruby/object:Gem::Version
162
+ version: '0'
163
+ requirements: []
164
+ rubyforge_project:
165
+ rubygems_version: 2.5.2
166
+ signing_key:
167
+ specification_version: 4
168
+ summary: Reliable fetcher for Sidekiq
169
+ test_files: []