sidekiq-power-fetch 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: afe6f74ebc8d0e8b63cbc035ca0fd7f0239a9148a6652a3f75a1a80ad4b24d55
4
+ data.tar.gz: c02dc4a2f5c8fd660841df3d082ce326440a981f73af3288ba7d61086cde9641
5
+ SHA512:
6
+ metadata.gz: 4c9162f7bb42087096c35bebe4faa5273283a01a15a0f3393ea07f8e8386737cd64743d51cd2d235aebd9a7c9be28f4999461dddbef49178d8c0053491a73bfc
7
+ data.tar.gz: 93651ad8a7724d6121066a3df93468e5862a15e554afd3ac68dd204954f220f251b43134c732bc4d3e254aabef5317911089ab7373a9d18e290f0553cde0b0e9
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sidekiq
4
+ class PowerFetch
5
+ class Heartbeat
6
+ LIFESPAN = 60 # seconds
7
+
8
+ def self.start(config)
9
+ new(config)
10
+ end
11
+
12
+ def self.started?
13
+ @started
14
+ end
15
+
16
+ def self.started=(value)
17
+ @started = value
18
+ end
19
+
20
+ def initialize(config)
21
+ raise "#{self.class} already started" if self.class.started?
22
+
23
+ @config = config
24
+
25
+ # Must pulse on startup, else races: other workers think the current
26
+ # process is dead, but it just didn't heartbeat yet.
27
+ @config.on(:startup) { pulse }
28
+ @config.on(:heartbeat) { pulse }
29
+ self.class.started = true
30
+ end
31
+
32
+ def self.key(identity)
33
+ id = identity.tr(":", "-")
34
+ "sidekiq-power-fetch-heartbeat-#{id}"
35
+ end
36
+
37
+ def pulse
38
+ @config.redis do |conn|
39
+ conn.set(key, 1, ex: LIFESPAN)
40
+ end
41
+
42
+ @config.logger.debug("[PowerFetch] Heartbeat for #{PowerFetch.identity}")
43
+ end
44
+
45
+ private
46
+
47
+ def key
48
+ @key ||= self.class.key(PowerFetch.identity)
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sidekiq
4
+ class PowerFetch
5
+ class Lock
6
+ DEFAULT_INTERVAL = 120 # 2 minutes
7
+ DEFAULT_RECOVER = 3600 # 1 hour
8
+ KEY = "sidekiq-power-fetch-lock"
9
+
10
+ def initialize(capsule)
11
+ @capsule = capsule
12
+ @taken_at = 0
13
+
14
+ # Sidekiq processes should attempt recovery every 2 minutes.
15
+ @interval = @capsule.config[:power_fetch_lock] || DEFAULT_INTERVAL
16
+ # Perform recovery at most every 1 hour.
17
+ @recover = @capsule.config[:power_fetch_recover] || DEFAULT_RECOVER
18
+ end
19
+
20
+ def lock
21
+ return unless take?
22
+
23
+ @taken_at = time
24
+ # Return value is "OK" or nil.
25
+ @capsule.redis do |conn|
26
+ conn.set(KEY, 1, nx: true, ex: @recover)
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def take?
33
+ time - @taken_at > @interval
34
+ end
35
+
36
+ def time
37
+ ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lock"
4
+
5
+ module Sidekiq
6
+ class PowerFetch
7
+ class Recover
8
+ # Defines the COUNT parameter that will be passed to Redis SCAN command
9
+ SCAN_COUNT = 1000
10
+
11
+ # How much time a job can be interrupted
12
+ RECOVERIES = 3
13
+
14
+ # Regexes for matching working queue keys
15
+ WORKING_QUEUE_REGEX = /\A#{WORKING_QUEUE_PREFIX}:(queue:.*):([^:]*:[0-9]*:[0-9a-f]*)\z/
16
+
17
+ def initialize(capsule)
18
+ @capsule = capsule
19
+ @lock = Lock.new(@capsule)
20
+ @recoveries = @capsule.config[:power_fetch_recoveries] || RECOVERIES
21
+ end
22
+
23
+ def lock
24
+ @lock.lock
25
+ end
26
+
27
+ # Detect "old" jobs and requeue them because the worker they were assigned
28
+ # to probably failed miserably.
29
+ def call
30
+ @capsule.logger.info("[PowerFetch] Recovering working queues")
31
+
32
+ @capsule.redis do |conn|
33
+ conn.scan(
34
+ match: "#{WORKING_QUEUE_PREFIX}:queue:*",
35
+ count: SCAN_COUNT
36
+ ) do |key|
37
+ # Identity format is "{hostname}:{pid}:{randomhex}
38
+ # Queue names may also have colons (namespaced).
39
+ # Expressing this in a single regex is unreadable
40
+ original_queue, identity = key.scan(WORKING_QUEUE_REGEX).flatten
41
+
42
+ next if original_queue.nil? || identity.nil?
43
+
44
+ if worker_dead?(identity, conn)
45
+ recover_working_queue!(original_queue, key)
46
+ end
47
+ end
48
+ end
49
+ end
50
+
51
+ # If you want this method to be run in a scope of multi connection
52
+ # you need to pass it
53
+ def requeue_job(queue, msg, conn)
54
+ with_connection(conn) do |conn|
55
+ conn.lpush(queue, Sidekiq.dump_json(msg))
56
+ end
57
+
58
+ @capsule.logger.info(
59
+ "[PowerFetch] Pushed job #{msg["jid"]} back to queue '#{queue}'"
60
+ )
61
+ end
62
+
63
+ private
64
+
65
+ def recover_working_queue!(original_queue, working_queue)
66
+ @capsule.redis do |conn|
67
+ while job = conn.rpop(working_queue)
68
+ preprocess_interrupted_job(job, original_queue)
69
+ end
70
+ end
71
+ end
72
+
73
+ def preprocess_interrupted_job(job, queue, conn = nil)
74
+ msg = Sidekiq.load_json(job)
75
+ msg["interrupted_count"] = msg["interrupted_count"].to_i + 1
76
+
77
+ if interruption_exhausted?(msg)
78
+ @capsule.logger.warn(
79
+ "[PowerFetch] Deleted job #{msg["class"]} jid #{msg["jid"]}, " \
80
+ "it was recovered too many times"
81
+ )
82
+ else
83
+ requeue_job(queue, msg, conn)
84
+ end
85
+ end
86
+
87
+ def interruption_exhausted?(msg)
88
+ return false if @recoveries < 0
89
+
90
+ msg["interrupted_count"].to_i >= @recoveries
91
+ end
92
+
93
+ # Yield block with an existing connection or creates another one
94
+ def with_connection(conn)
95
+ return yield(conn) if conn
96
+
97
+ @capsule.redis { |redis_conn| yield(redis_conn) }
98
+ end
99
+
100
+ def worker_dead?(identity, conn)
101
+ !conn.get(Heartbeat.key(identity))
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sidekiq
4
+ class PowerFetch
5
+ class UnitOfWork
6
+ attr_reader :queue
7
+ attr_reader :job
8
+
9
+ def initialize(queue, job)
10
+ @queue = queue
11
+ @job = job
12
+ end
13
+
14
+ def acknowledge
15
+ Sidekiq.redis do |conn|
16
+ conn.lrem(PowerFetch.working_queue_name(@queue), 1, @job)
17
+ end
18
+ end
19
+
20
+ def queue_name
21
+ @queue.sub(/.*queue:/, "")
22
+ end
23
+
24
+ def requeue
25
+ Sidekiq.redis do |conn|
26
+ conn.multi do |multi|
27
+ multi.lpush(@queue, @job)
28
+ multi.lrem(PowerFetch.working_queue_name(@queue), 1, @job)
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "power_fetch/heartbeat"
4
+ require_relative "power_fetch/unit_of_work"
5
+ # "recover" is required at the bottom of the file, after
6
+ # PowerFetch::WORKING_QUEUE_PREFIX constant is defined.
7
+
8
+ module Sidekiq
9
+ class PowerFetch
10
+ WORKING_QUEUE_PREFIX = "working"
11
+
12
+ # We don't use Redis' blocking operations for fetch so
13
+ # we inject a regular sleep into the loop.
14
+ IDLE_TIMEOUT = 5 # seconds
15
+
16
+ def self.identity
17
+ @identity ||= begin
18
+ hostname = ENV["DYNO"] || Socket.gethostname
19
+ pid = ::Process.pid
20
+ process_nonce = SecureRandom.hex(6)
21
+
22
+ "#{hostname}:#{pid}:#{process_nonce}"
23
+ end
24
+ end
25
+
26
+ def self.working_queue_name(queue)
27
+ "#{WORKING_QUEUE_PREFIX}:#{queue}:#{identity}"
28
+ end
29
+
30
+ def initialize(capsule)
31
+ raise ArgumentError, "missing queue list" unless capsule.queues
32
+ @capsule = capsule
33
+ @strictly_ordered_queues = capsule.mode == :strict
34
+ @queues = @capsule.queues.map { |q| "queue:#{q}" }
35
+ @queues.uniq! if @strictly_ordered_queues
36
+
37
+ @recover = Recover.new(@capsule)
38
+ @capsule.logger.info("[PowerFetch] Activated!")
39
+ end
40
+
41
+ def retrieve_work
42
+ if @recover.lock
43
+ @recover.call
44
+ end
45
+
46
+ queues_list = @strictly_ordered_queues ? @queues : @queues.shuffle
47
+
48
+ queues_list.each do |queue|
49
+ work = @capsule.redis do |conn|
50
+ # Can't use 'blmove' here: empty blocked queue would then block
51
+ # other, potentially non-empty, queues.
52
+ conn.lmove(queue, self.class.working_queue_name(queue), :right, :left)
53
+ end
54
+
55
+ return UnitOfWork.new(queue, work) if work
56
+ end
57
+
58
+ # We didn't find a job in any of the configured queues. Let's sleep a bit
59
+ # to avoid uselessly burning too much CPU
60
+ sleep(IDLE_TIMEOUT)
61
+
62
+ nil
63
+ end
64
+
65
+ # Called by sidekiq on "hard shutdown": when shutdown is reached, and there
66
+ # are still busy threads. The threads are shutdown, but their jobs are
67
+ # requeued.
68
+ # https://github.com/sidekiq/sidekiq/blob/323a5cfaefdde20588f5ffdf0124691db83fd315/lib/sidekiq/manager.rb#L107
69
+ def bulk_requeue(inprogress)
70
+ return if inprogress.empty?
71
+
72
+ @capsule.redis do |conn|
73
+ inprogress.each do |unit_of_work|
74
+ conn.multi do |multi|
75
+ msg = Sidekiq.load_json(unit_of_work.job)
76
+ @recover.requeue_job(unit_of_work.queue, msg, multi)
77
+
78
+ multi.lrem(self.class.working_queue_name(unit_of_work.queue), 1, unit_of_work.job)
79
+ end
80
+ end
81
+ end
82
+ rescue => e
83
+ @capsule.logger.warn("[PowerFetch] Failed to requeue #{inprogress.size} jobs: #{e.message}")
84
+ end
85
+ end
86
+ end
87
+
88
+ require_relative "power_fetch/recover"
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sidekiq"
4
+ require "sidekiq/api"
5
+
6
+ require_relative "sidekiq/power_fetch"
7
+
8
+ Sidekiq.configure_server do |config|
9
+ config[:fetch_class] = Sidekiq::PowerFetch
10
+
11
+ # There's only one Heartbeat per process.
12
+ # Starting it as soon as possible.
13
+ Sidekiq::PowerFetch::Heartbeat.start(config)
14
+ end
metadata ADDED
@@ -0,0 +1,106 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: sidekiq-power-fetch
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - TEA
8
+ - GitLab
9
+ - Bruno Sutic
10
+ autorequire:
11
+ bindir: bin
12
+ cert_chain: []
13
+ date: 2023-08-10 00:00:00.000000000 Z
14
+ dependencies:
15
+ - !ruby/object:Gem::Dependency
16
+ name: sidekiq
17
+ requirement: !ruby/object:Gem::Requirement
18
+ requirements:
19
+ - - "~>"
20
+ - !ruby/object:Gem::Version
21
+ version: '7.0'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - "~>"
27
+ - !ruby/object:Gem::Version
28
+ version: '7.0'
29
+ - !ruby/object:Gem::Dependency
30
+ name: rspec
31
+ requirement: !ruby/object:Gem::Requirement
32
+ requirements:
33
+ - - "~>"
34
+ - !ruby/object:Gem::Version
35
+ version: '3.12'
36
+ type: :development
37
+ prerelease: false
38
+ version_requirements: !ruby/object:Gem::Requirement
39
+ requirements:
40
+ - - "~>"
41
+ - !ruby/object:Gem::Version
42
+ version: '3.12'
43
+ - !ruby/object:Gem::Dependency
44
+ name: rubocop-rspec
45
+ requirement: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - "~>"
48
+ - !ruby/object:Gem::Version
49
+ version: '2.23'
50
+ type: :development
51
+ prerelease: false
52
+ version_requirements: !ruby/object:Gem::Requirement
53
+ requirements:
54
+ - - "~>"
55
+ - !ruby/object:Gem::Version
56
+ version: '2.23'
57
+ - !ruby/object:Gem::Dependency
58
+ name: standard
59
+ requirement: !ruby/object:Gem::Requirement
60
+ requirements:
61
+ - - "~>"
62
+ - !ruby/object:Gem::Version
63
+ version: '1.30'
64
+ type: :development
65
+ prerelease: false
66
+ version_requirements: !ruby/object:Gem::Requirement
67
+ requirements:
68
+ - - "~>"
69
+ - !ruby/object:Gem::Version
70
+ version: '1.30'
71
+ description:
72
+ email: code@brunosutic.com
73
+ executables: []
74
+ extensions: []
75
+ extra_rdoc_files: []
76
+ files:
77
+ - lib/sidekiq-power-fetch.rb
78
+ - lib/sidekiq/power_fetch.rb
79
+ - lib/sidekiq/power_fetch/heartbeat.rb
80
+ - lib/sidekiq/power_fetch/lock.rb
81
+ - lib/sidekiq/power_fetch/recover.rb
82
+ - lib/sidekiq/power_fetch/unit_of_work.rb
83
+ homepage: https://gitlab.com/bruno-/sidekiq-power-fetch
84
+ licenses:
85
+ - LGPL-3.0
86
+ metadata: {}
87
+ post_install_message:
88
+ rdoc_options: []
89
+ require_paths:
90
+ - lib
91
+ required_ruby_version: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: '0'
96
+ required_rubygems_version: !ruby/object:Gem::Requirement
97
+ requirements:
98
+ - - ">="
99
+ - !ruby/object:Gem::Version
100
+ version: '0'
101
+ requirements: []
102
+ rubygems_version: 3.4.14
103
+ signing_key:
104
+ specification_version: 4
105
+ summary: Improved fetch for Sidekiq 7+ and Redis 6.2.0+
106
+ test_files: []