sidekiq-power-fetch 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: 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: []