sidekiq-buffered 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/.rspec +2 -0
- data/.rubocop.yml +64 -0
- data/.tool-versions +1 -0
- data/CHANGELOG.md +32 -0
- data/Gemfile +6 -0
- data/LICENSE +21 -0
- data/README.md +432 -0
- data/Rakefile +8 -0
- data/lib/active_job/queue_adapters/sidekiq_buffered_adapter.rb +61 -0
- data/lib/sidekiq/buffered/active_job_execution.rb +49 -0
- data/lib/sidekiq/buffered/active_job_retry.rb +12 -0
- data/lib/sidekiq/buffered/batch.rb +70 -0
- data/lib/sidekiq/buffered/batch_context.rb +37 -0
- data/lib/sidekiq/buffered/batch_record.rb +79 -0
- data/lib/sidekiq/buffered/client.rb +109 -0
- data/lib/sidekiq/buffered/client_middleware.rb +38 -0
- data/lib/sidekiq/buffered/collector.rb +63 -0
- data/lib/sidekiq/buffered/config.rb +26 -0
- data/lib/sidekiq/buffered/dispatch.rb +95 -0
- data/lib/sidekiq/buffered/events.rb +29 -0
- data/lib/sidekiq/buffered/execution.rb +143 -0
- data/lib/sidekiq/buffered/heartbeat.rb +96 -0
- data/lib/sidekiq/buffered/inline.rb +17 -0
- data/lib/sidekiq/buffered/input.rb +122 -0
- data/lib/sidekiq/buffered/periodic_thread.rb +45 -0
- data/lib/sidekiq/buffered/poller.rb +81 -0
- data/lib/sidekiq/buffered/processor.rb +42 -0
- data/lib/sidekiq/buffered/registry.rb +38 -0
- data/lib/sidekiq/buffered/retry_callbacks.rb +32 -0
- data/lib/sidekiq/buffered/scheduled_input.rb +21 -0
- data/lib/sidekiq/buffered/scope.rb +17 -0
- data/lib/sidekiq/buffered/script.rb +48 -0
- data/lib/sidekiq/buffered/scripts/ack.lua +5 -0
- data/lib/sidekiq/buffered/scripts/append.lua +64 -0
- data/lib/sidekiq/buffered/scripts/claim.lua +29 -0
- data/lib/sidekiq/buffered/scripts/cleanup_failed.lua +7 -0
- data/lib/sidekiq/buffered/scripts/discard.lua +7 -0
- data/lib/sidekiq/buffered/scripts/exhaust.lua +10 -0
- data/lib/sidekiq/buffered/scripts/extend.lua +7 -0
- data/lib/sidekiq/buffered/scripts/flush_due.lua +25 -0
- data/lib/sidekiq/buffered/scripts/helpers/delete_batch.lua +21 -0
- data/lib/sidekiq/buffered/scripts/helpers/materialize_batch.lua +21 -0
- data/lib/sidekiq/buffered/scripts/helpers/redis_time.lua +6 -0
- data/lib/sidekiq/buffered/scripts/record_dispatch.lua +5 -0
- data/lib/sidekiq/buffered/scripts/recover.lua +21 -0
- data/lib/sidekiq/buffered/scripts/release.lua +8 -0
- data/lib/sidekiq/buffered/scripts/replay.lua +11 -0
- data/lib/sidekiq/buffered/scripts/retrying.lua +13 -0
- data/lib/sidekiq/buffered/scripts/tick.lua +10 -0
- data/lib/sidekiq/buffered/scripts.rb +23 -0
- data/lib/sidekiq/buffered/server_middleware.rb +51 -0
- data/lib/sidekiq/buffered/setter.rb +41 -0
- data/lib/sidekiq/buffered/statistics.rb +130 -0
- data/lib/sidekiq/buffered/store.rb +363 -0
- data/lib/sidekiq/buffered/testing.rb +65 -0
- data/lib/sidekiq/buffered/thread_local.rb +14 -0
- data/lib/sidekiq/buffered/version.rb +5 -0
- data/lib/sidekiq/buffered.rb +172 -0
- data/lib/sidekiq-buffered.rb +1 -0
- data/sidekiq-buffered.gemspec +37 -0
- metadata +216 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
module Sidekiq
|
|
2
|
+
module Buffered
|
|
3
|
+
# One worker's attempt at a batch: the lease token it holds, the record it
|
|
4
|
+
# claimed, and whether perform completed. ServerMiddleware claims and
|
|
5
|
+
# acknowledges around the server chain; without it, the processor does both.
|
|
6
|
+
class Execution
|
|
7
|
+
CURRENT_KEY = :sidekiq_buffered_execution
|
|
8
|
+
|
|
9
|
+
attr_reader :batch_id, :jid, :token, :record
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
# Sidekiq's default retry delay, and the most jitter it adds on top.
|
|
13
|
+
def default_retry_delay(count)
|
|
14
|
+
(count**4) + 15
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def max_retry_jitter(count)
|
|
18
|
+
10 * (count + 1)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Sidekiq schedules a failed processor's retry on the job's thread after
|
|
22
|
+
# the server middleware has unwound, so a claimed execution stays
|
|
23
|
+
# current on its thread until the middleware or a retry callback
|
|
24
|
+
# finishes it.
|
|
25
|
+
def current
|
|
26
|
+
Thread.current[CURRENT_KEY]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def current=(execution)
|
|
30
|
+
Thread.current[CURRENT_KEY] = execution
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# The current execution, if it is the one running processor payload +job+.
|
|
34
|
+
def for(job)
|
|
35
|
+
execution = current
|
|
36
|
+
execution if execution && execution.jid == job["jid"] && execution.batch_id == job["args"]&.first
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Sidekiq's retry callbacks, reached through RetryCallbacks on the job class.
|
|
40
|
+
def retry_in(job, count, exception, declared)
|
|
41
|
+
execution = self.for(job)
|
|
42
|
+
return execution.retry_in(count, exception, job, declared) if execution
|
|
43
|
+
|
|
44
|
+
Sidekiq.logger.debug { "sidekiq-buffered retry callback outside its execution: jid=#{job['jid']}" }
|
|
45
|
+
declared_callback(declared, count, exception, job)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def retries_exhausted(job, exception, declared)
|
|
49
|
+
execution = self.for(job)
|
|
50
|
+
return execution.retries_exhausted(job, exception, declared) if execution
|
|
51
|
+
|
|
52
|
+
Sidekiq.logger.debug { "sidekiq-buffered exhausted callback outside its execution: jid=#{job['jid']}" }
|
|
53
|
+
declared_callback(declared, job, exception)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# A callback the job class declared itself; its errors never change delivery.
|
|
57
|
+
def declared_callback(callback, *)
|
|
58
|
+
callback&.call(*)
|
|
59
|
+
rescue StandardError => e
|
|
60
|
+
Sidekiq.logger.warn("sidekiq-buffered retry callback: #{e.class}: #{e.message}")
|
|
61
|
+
nil
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def initialize(batch_id, jid:, store: Buffered.store)
|
|
66
|
+
@batch_id = batch_id
|
|
67
|
+
@jid = jid
|
|
68
|
+
@store = store
|
|
69
|
+
@token = SecureRandom.hex(12)
|
|
70
|
+
@record = nil
|
|
71
|
+
@performed = false
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# A successful claim makes this the thread's current execution.
|
|
75
|
+
def claim
|
|
76
|
+
@record = @store.claim(batch_id, token: jid, lease_token: token)
|
|
77
|
+
self.class.current = self if @record
|
|
78
|
+
@record
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def performed?
|
|
82
|
+
@performed
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def job_class
|
|
86
|
+
Object.const_get(record.job_class)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def run
|
|
90
|
+
BatchContext.with(record) do
|
|
91
|
+
if record.active_job?
|
|
92
|
+
ActiveJobExecution.perform(record, jid)
|
|
93
|
+
else
|
|
94
|
+
worker = job_class.new
|
|
95
|
+
worker.jid = jid if worker.respond_to?(:jid=)
|
|
96
|
+
worker.perform(record.arguments)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
@performed = true
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def ack
|
|
103
|
+
@store.ack(batch_id, token:)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def release
|
|
107
|
+
@store.release(batch_id, token:)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def exhaust(exception = nil)
|
|
111
|
+
@store.exhaust(batch_id, token:, exception:)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Sidekiq needs nothing further from this execution.
|
|
115
|
+
def finish
|
|
116
|
+
self.class.current = nil if self.class.current.equal?(self)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Sidekiq is about to retry the processor. The job class's own retry_in
|
|
120
|
+
# callback picks the delay, and the batch stays protected for that long.
|
|
121
|
+
def retry_in(count, exception, job, declared = nil)
|
|
122
|
+
delay = self.class.declared_callback(declared, count, exception, job)
|
|
123
|
+
case delay
|
|
124
|
+
when :discard then ack
|
|
125
|
+
when :kill then return delay # Sidekiq calls retries_exhausted next.
|
|
126
|
+
else
|
|
127
|
+
explicit = delay.respond_to?(:to_i) && delay.to_i.positive?
|
|
128
|
+
seconds = explicit ? delay.to_i : self.class.default_retry_delay(count)
|
|
129
|
+
@store.mark_retrying(batch_id, token:, exception:, delay: seconds + self.class.max_retry_jitter(count))
|
|
130
|
+
end
|
|
131
|
+
finish
|
|
132
|
+
delay
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def retries_exhausted(job, exception, declared = nil)
|
|
136
|
+
exhaust(exception)
|
|
137
|
+
self.class.declared_callback(declared, job, exception)
|
|
138
|
+
ensure
|
|
139
|
+
finish
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
module Sidekiq
|
|
2
|
+
module Buffered
|
|
3
|
+
# One thread per process extends the leases of batches that are currently
|
|
4
|
+
# executing, so lease_timeout bounds crash recovery instead of perform time.
|
|
5
|
+
class Heartbeat < PeriodicThread
|
|
6
|
+
THREAD_NAME = "sidekiq-buffered-heartbeat".freeze
|
|
7
|
+
MIN_INTERVAL = 0.1
|
|
8
|
+
IDLE_INTERVAL = 1.0
|
|
9
|
+
Lease = Struct.new(:batch_id, :token, :timeout, :redis_pool)
|
|
10
|
+
|
|
11
|
+
@mutex = Mutex.new
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
def instance
|
|
15
|
+
@mutex.synchronize { @instance ||= new }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def track(batch_id, token:, timeout:, redis_pool:)
|
|
19
|
+
heartbeat = instance
|
|
20
|
+
heartbeat.track(batch_id, token:, timeout:, redis_pool:)
|
|
21
|
+
heartbeat.start
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def untrack(batch_id)
|
|
25
|
+
@instance&.untrack(batch_id)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def tracked_ids
|
|
29
|
+
@instance&.tracked_ids || []
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def start
|
|
33
|
+
instance.start
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def stop
|
|
37
|
+
heartbeat = @mutex.synchronize do
|
|
38
|
+
current = @instance
|
|
39
|
+
@instance = nil
|
|
40
|
+
current
|
|
41
|
+
end
|
|
42
|
+
heartbeat&.stop
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def initialize
|
|
47
|
+
super
|
|
48
|
+
@leases = {}
|
|
49
|
+
@mutex = Mutex.new
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def track(batch_id, token:, timeout:, redis_pool:)
|
|
53
|
+
@mutex.synchronize { @leases[batch_id] = Lease.new(batch_id, token, Integer(timeout), redis_pool) }
|
|
54
|
+
wakeup
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def untrack(batch_id)
|
|
58
|
+
@mutex.synchronize { @leases.delete(batch_id) }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def tracked_ids
|
|
62
|
+
@mutex.synchronize { @leases.keys }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def tick
|
|
66
|
+
leases = @mutex.synchronize { @leases.values }
|
|
67
|
+
return if leases.empty?
|
|
68
|
+
|
|
69
|
+
leases.group_by(&:redis_pool).each do |redis_pool, group|
|
|
70
|
+
Store.new(redis_pool).extend_leases(group).each { |lease| lost(lease) }
|
|
71
|
+
end
|
|
72
|
+
rescue StandardError => e
|
|
73
|
+
Sidekiq.logger.error("sidekiq-buffered heartbeat: #{e.class}: #{e.message}")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def interval
|
|
77
|
+
timeouts = @mutex.synchronize { @leases.values.map(&:timeout) }
|
|
78
|
+
return IDLE_INTERVAL if timeouts.empty?
|
|
79
|
+
|
|
80
|
+
[timeouts.min / 3.0, MIN_INTERVAL].max
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
# A lease that finished normally between the snapshot and the extension is
|
|
86
|
+
# already untracked; only a lease we still hold has really been lost.
|
|
87
|
+
def lost(lease)
|
|
88
|
+
still_tracked = @mutex.synchronize { @leases.delete(lease.batch_id) if @leases[lease.batch_id].equal?(lease) }
|
|
89
|
+
return unless still_tracked
|
|
90
|
+
|
|
91
|
+
Sidekiq.logger.warn("sidekiq-buffered lease lost while running: batch=#{lease.batch_id}")
|
|
92
|
+
Buffered.notify(:lease_lost, batch_id: lease.batch_id)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module Sidekiq
|
|
2
|
+
module Buffered
|
|
3
|
+
# Marks Setter#perform_inline so the client middleware lets the payload
|
|
4
|
+
# through to synchronous execution instead of buffering it.
|
|
5
|
+
module Inline
|
|
6
|
+
KEY = :sidekiq_buffered_inline
|
|
7
|
+
|
|
8
|
+
def self.active?
|
|
9
|
+
Thread.current[KEY] == true
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.with(&)
|
|
13
|
+
ThreadLocal.with(KEY, true, &)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
module Sidekiq
|
|
2
|
+
module Buffered
|
|
3
|
+
# One buffered input as it leaves Sidekiq's client middleware chain: a job
|
|
4
|
+
# payload whose single argument is a Hash. Knows which buffer the input
|
|
5
|
+
# belongs to and how it is stored inside a batch.
|
|
6
|
+
class Input
|
|
7
|
+
# Fields Sidekiq or this gem own. Anything else on a payload is metadata
|
|
8
|
+
# other client middleware attached, and it travels with the input.
|
|
9
|
+
PAYLOAD_FIELDS = %w[class args jid queue retry retry_for retry_queue dead backtrace at enqueued_at
|
|
10
|
+
created_at active_job wrapped pool client_class batch_size buffered_context
|
|
11
|
+
buffered_active_job_context].freeze
|
|
12
|
+
# Sidekiq options that shape execution and therefore separate buffers.
|
|
13
|
+
EXECUTION_FIELDS = %w[queue retry retry_for retry_queue dead backtrace].freeze
|
|
14
|
+
|
|
15
|
+
# Inputs with the same identity share a buffer, and so a batch.
|
|
16
|
+
Identity = Data.define(:job_class, :group, :config, :context)
|
|
17
|
+
|
|
18
|
+
class << self
|
|
19
|
+
# Rewrites +payload+ in place so its argument list is one string-keyed
|
|
20
|
+
# Hash that carries the grouping key.
|
|
21
|
+
def normalize!(payload)
|
|
22
|
+
payload["args"] = normalize_args(payload["class"], payload["args"])
|
|
23
|
+
payload
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def normalize_args(job_class, args)
|
|
27
|
+
input = new("class" => job_class, "args" => stringify_keys(args)).validate!
|
|
28
|
+
[input.arguments]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def encode_group(value)
|
|
32
|
+
Sidekiq.dump_json(value)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def decode_group(raw)
|
|
36
|
+
return if raw.nil? || raw.empty?
|
|
37
|
+
|
|
38
|
+
Sidekiq.load_json(raw)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def stringify_keys(value)
|
|
44
|
+
case value
|
|
45
|
+
when Hash then value.to_h { |key, nested| [key.to_s, stringify_keys(nested)] }
|
|
46
|
+
when Array then value.map { |entry| stringify_keys(entry) }
|
|
47
|
+
else value
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
attr_reader :payload
|
|
53
|
+
|
|
54
|
+
def initialize(payload)
|
|
55
|
+
@payload = payload
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def job_class
|
|
59
|
+
@payload["class"].to_s
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def jid
|
|
63
|
+
@payload["jid"]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def config
|
|
67
|
+
Buffered.config_for(job_class)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def arguments
|
|
71
|
+
@arguments ||= begin
|
|
72
|
+
args = @payload["args"]
|
|
73
|
+
unless args.is_a?(Array) && args.size == 1 && args.first.is_a?(Hash)
|
|
74
|
+
raise ArgumentError, "#{job_class} expects a single Hash argument"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
args.first
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Raises unless the argument list is one Hash that carries the grouping key.
|
|
82
|
+
def validate!
|
|
83
|
+
key = config.group_by
|
|
84
|
+
return self if key.nil? || arguments.key?(key)
|
|
85
|
+
|
|
86
|
+
raise ArgumentError, "#{job_class} is missing grouping key #{key.inspect}"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# The encoded grouping value, or "" for ungrouped jobs.
|
|
90
|
+
def group
|
|
91
|
+
@group ||= begin
|
|
92
|
+
key = config.group_by
|
|
93
|
+
key ? self.class.encode_group(validate!.arguments.fetch(key)) : ""
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Middleware metadata plus the execution options, kept with the input so
|
|
98
|
+
# the batch runs under the same context its inputs were enqueued with.
|
|
99
|
+
def context
|
|
100
|
+
@context ||= begin
|
|
101
|
+
context = @payload.except(*PAYLOAD_FIELDS).merge(@payload.slice(*EXECUTION_FIELDS))
|
|
102
|
+
if (data = @payload["active_job"])
|
|
103
|
+
context["buffered_active_job_context"] = data.values_at("locale", "timezone")
|
|
104
|
+
end
|
|
105
|
+
context
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def identity
|
|
110
|
+
Identity.new(job_class:, group:, config:, context:)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# The JSON stored in the buffer. Redis stamps buffered_at into it on admission.
|
|
114
|
+
def dump
|
|
115
|
+
item = { "jid" => jid, "args" => arguments }
|
|
116
|
+
item["context"] = context unless context.empty?
|
|
117
|
+
item["active_job"] = @payload["active_job"] if @payload.key?("active_job")
|
|
118
|
+
Sidekiq.dump_json(item)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
module Sidekiq
|
|
2
|
+
module Buffered
|
|
3
|
+
# A named background thread that calls +tick+, sleeps +interval+ seconds,
|
|
4
|
+
# and repeats until stopped. Subclasses define both, plus THREAD_NAME.
|
|
5
|
+
class PeriodicThread
|
|
6
|
+
def initialize
|
|
7
|
+
@stop = false
|
|
8
|
+
@thread = nil
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def start
|
|
12
|
+
return if @thread&.alive?
|
|
13
|
+
|
|
14
|
+
@stop = false
|
|
15
|
+
@thread = Thread.new do
|
|
16
|
+
Thread.current.name = self.class::THREAD_NAME
|
|
17
|
+
run
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def stop
|
|
22
|
+
@stop = true
|
|
23
|
+
wakeup
|
|
24
|
+
ensure
|
|
25
|
+
@thread&.join(1)
|
|
26
|
+
@thread = nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def run
|
|
32
|
+
until @stop
|
|
33
|
+
tick
|
|
34
|
+
sleep(interval) unless @stop
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def wakeup
|
|
39
|
+
@thread&.wakeup
|
|
40
|
+
rescue ThreadError
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
require "sidekiq/api"
|
|
2
|
+
|
|
3
|
+
module Sidekiq
|
|
4
|
+
module Buffered
|
|
5
|
+
# Flushes due buffers, recovers stalled batches, and cleans up failures.
|
|
6
|
+
# Idle sleeps stretch with the number of Sidekiq processes, like Sidekiq's
|
|
7
|
+
# own scheduler, and shrink to the next known deadline.
|
|
8
|
+
class Poller < PeriodicThread
|
|
9
|
+
THREAD_NAME = "sidekiq-buffered-poller".freeze
|
|
10
|
+
MAX_ERROR_INTERVAL = 30
|
|
11
|
+
MIN_SLEEP = 0.01
|
|
12
|
+
FLUSH_LIMIT = 500
|
|
13
|
+
PROCESS_COUNT_TTL = 30
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
def start
|
|
17
|
+
@instance ||= new
|
|
18
|
+
@instance.start
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def stop
|
|
22
|
+
@instance&.stop
|
|
23
|
+
@instance = nil
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def initialize(store = Buffered.store)
|
|
28
|
+
super()
|
|
29
|
+
@store = store
|
|
30
|
+
@failures = 0
|
|
31
|
+
@busy = false
|
|
32
|
+
@now = nil
|
|
33
|
+
@next_at = nil
|
|
34
|
+
@process_count = nil
|
|
35
|
+
@process_counted_at = nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def tick
|
|
39
|
+
due = @store.due(limit: FLUSH_LIMIT)
|
|
40
|
+
@store.flush_members(due.buffers, now: due.now)
|
|
41
|
+
@store.recover(due.outstanding, now: due.now)
|
|
42
|
+
@store.cleanup_failed(now: due.now)
|
|
43
|
+
@busy = due.buffers.size >= FLUSH_LIMIT || due.outstanding.size >= FLUSH_LIMIT
|
|
44
|
+
@next_at = due.next_at
|
|
45
|
+
@now = due.now
|
|
46
|
+
@failures = 0
|
|
47
|
+
rescue StandardError => e
|
|
48
|
+
@failures += 1
|
|
49
|
+
Sidekiq.logger.error("sidekiq-buffered poller: #{e.class}: #{e.message}")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Errors back off exponentially, a full page polls again at once, and
|
|
53
|
+
# otherwise the poller sleeps until the next known deadline.
|
|
54
|
+
def interval
|
|
55
|
+
return [Buffered.poll_interval * (2**[@failures, 20].min), MAX_ERROR_INTERVAL].min if @failures.positive?
|
|
56
|
+
return MIN_SLEEP if @busy
|
|
57
|
+
|
|
58
|
+
idle = scaled_interval
|
|
59
|
+
idle = [idle, @next_at - @now + (rand * Buffered.poll_interval)].min if @next_at && @now
|
|
60
|
+
[idle, MIN_SLEEP].max
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
# Average one poll per poll_interval across the cluster, with jitter.
|
|
66
|
+
def scaled_interval
|
|
67
|
+
Buffered.poll_interval * process_count * (0.5 + rand)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def process_count
|
|
71
|
+
at = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
72
|
+
return @process_count if @process_count && at - @process_counted_at < PROCESS_COUNT_TTL
|
|
73
|
+
|
|
74
|
+
@process_counted_at = at
|
|
75
|
+
@process_count = [Sidekiq::ProcessSet.new(false).size.to_i, 1].max
|
|
76
|
+
rescue StandardError
|
|
77
|
+
@process_count = 1
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
module Sidekiq
|
|
2
|
+
module Buffered
|
|
3
|
+
# The Sidekiq job that runs one batch. Its only argument is the batch ID.
|
|
4
|
+
class Processor
|
|
5
|
+
include Sidekiq::Job
|
|
6
|
+
|
|
7
|
+
# Set by ServerMiddleware once it has claimed the batch.
|
|
8
|
+
attr_accessor :execution
|
|
9
|
+
|
|
10
|
+
def perform(batch_id)
|
|
11
|
+
if execution
|
|
12
|
+
execution.run # ServerMiddleware acknowledges after the rest of the chain.
|
|
13
|
+
else
|
|
14
|
+
# No middleware in front (perform_inline, tests): own the whole lifecycle.
|
|
15
|
+
self.execution = Execution.new(batch_id, jid:)
|
|
16
|
+
return unless execution.claim
|
|
17
|
+
|
|
18
|
+
execution.run
|
|
19
|
+
execution.ack
|
|
20
|
+
execution.finish
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Sidekiq 7.0 reads retry callbacks from the job instance; later versions
|
|
25
|
+
# read them from the payload's wrapped class. Both reach the job class's
|
|
26
|
+
# RetryCallbacks.
|
|
27
|
+
def sidekiq_retry_in_block
|
|
28
|
+
->(count, exception, job) { retry_callbacks&.sidekiq_retry_in_block&.call(count, exception, job) }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def sidekiq_retries_exhausted_block
|
|
32
|
+
->(job, exception) { retry_callbacks&.sidekiq_retries_exhausted_block&.call(job, exception) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def retry_callbacks
|
|
38
|
+
execution&.job_class
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module Sidekiq
|
|
2
|
+
module Buffered
|
|
3
|
+
# The +buffered+ declarations, by job class name. Lookups accept a class or
|
|
4
|
+
# its name, since Redis and Sidekiq payloads carry names, and a subclass
|
|
5
|
+
# inherits the nearest ancestor's declaration.
|
|
6
|
+
class Registry
|
|
7
|
+
def initialize
|
|
8
|
+
@configs = {}
|
|
9
|
+
@mutex = Mutex.new
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def register(klass, config)
|
|
13
|
+
@mutex.synchronize { @configs[klass.name] = config }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def registered?(klass)
|
|
17
|
+
!self[klass].nil?
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def fetch(klass)
|
|
21
|
+
self[klass] || raise(KeyError, "No buffered configuration for #{klass}")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def [](klass)
|
|
25
|
+
name = klass.is_a?(Class) ? klass.name : klass.to_s
|
|
26
|
+
@configs[name] || ancestor_config(klass.is_a?(Class) ? klass : Object.const_get(name))
|
|
27
|
+
rescue NameError
|
|
28
|
+
nil
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def ancestor_config(klass)
|
|
34
|
+
klass.superclass && self[klass.superclass]
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
module Sidekiq
|
|
2
|
+
module Buffered
|
|
3
|
+
# Sidekiq resolves a processor payload's retry callbacks through its
|
|
4
|
+
# +wrapped+ class, which is the buffered job class. Prepended to that class's
|
|
5
|
+
# singleton by +buffered+, this answers those lookups with callbacks that
|
|
6
|
+
# record the batch's retry state and then run whatever the class declared
|
|
7
|
+
# with +sidekiq_retry_in+ and +sidekiq_retries_exhausted+.
|
|
8
|
+
module RetryCallbacks
|
|
9
|
+
RetryIn = Data.define(:declared) do
|
|
10
|
+
def call(count, exception, job)
|
|
11
|
+
Execution.retry_in(job, count, exception, declared)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
RetriesExhausted = Data.define(:declared) do
|
|
16
|
+
def call(job, exception)
|
|
17
|
+
Execution.retries_exhausted(job, exception, declared)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def sidekiq_retry_in_block
|
|
22
|
+
declared = defined?(super) ? super : nil
|
|
23
|
+
declared.is_a?(RetryIn) ? declared : RetryIn.new(declared)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def sidekiq_retries_exhausted_block
|
|
27
|
+
declared = defined?(super) ? super : nil
|
|
28
|
+
declared.is_a?(RetriesExhausted) ? declared : RetriesExhausted.new(declared)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module Sidekiq
|
|
2
|
+
module Buffered
|
|
3
|
+
# Native Sidekiq scheduling and retries protect inputs waiting for admission.
|
|
4
|
+
class ScheduledInput
|
|
5
|
+
include Sidekiq::Job
|
|
6
|
+
|
|
7
|
+
# Client-only options never belong in a persisted payload.
|
|
8
|
+
CLIENT_FIELDS = %w[client_class pool].freeze
|
|
9
|
+
|
|
10
|
+
def self.wrap(payload)
|
|
11
|
+
input = Sidekiq.load_json(Sidekiq.dump_json(payload.except("at", *CLIENT_FIELDS)))
|
|
12
|
+
payload.except(*CLIENT_FIELDS).merge("class" => name, "args" => [input])
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def perform(payload)
|
|
16
|
+
Object.const_get(payload.fetch("class")) # Load the buffered declaration before admission.
|
|
17
|
+
Buffered.store.append([payload])
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module Sidekiq
|
|
2
|
+
module Buffered
|
|
3
|
+
# Which job class and group an inspection or flush applies to. A class or
|
|
4
|
+
# its name narrows by class; a group value narrows by group.
|
|
5
|
+
class Scope
|
|
6
|
+
def initialize(job_class: nil, group: Buffered::ANY_GROUP)
|
|
7
|
+
@job_class = job_class.is_a?(Class) ? job_class.name : job_class&.to_s
|
|
8
|
+
@group = group
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# +job_class+ is a stored class name and +group+ a decoded grouping value.
|
|
12
|
+
def matches?(job_class, group)
|
|
13
|
+
(@job_class.nil? || job_class == @job_class) && (@group.equal?(Buffered::ANY_GROUP) || group == @group)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|