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 +7 -0
- data/lib/sidekiq/power_fetch/heartbeat.rb +52 -0
- data/lib/sidekiq/power_fetch/lock.rb +41 -0
- data/lib/sidekiq/power_fetch/recover.rb +105 -0
- data/lib/sidekiq/power_fetch/unit_of_work.rb +34 -0
- data/lib/sidekiq/power_fetch.rb +88 -0
- data/lib/sidekiq-power-fetch.rb +14 -0
- metadata +106 -0
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: []
|