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.
Files changed (62) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +2 -0
  3. data/.rubocop.yml +64 -0
  4. data/.tool-versions +1 -0
  5. data/CHANGELOG.md +32 -0
  6. data/Gemfile +6 -0
  7. data/LICENSE +21 -0
  8. data/README.md +432 -0
  9. data/Rakefile +8 -0
  10. data/lib/active_job/queue_adapters/sidekiq_buffered_adapter.rb +61 -0
  11. data/lib/sidekiq/buffered/active_job_execution.rb +49 -0
  12. data/lib/sidekiq/buffered/active_job_retry.rb +12 -0
  13. data/lib/sidekiq/buffered/batch.rb +70 -0
  14. data/lib/sidekiq/buffered/batch_context.rb +37 -0
  15. data/lib/sidekiq/buffered/batch_record.rb +79 -0
  16. data/lib/sidekiq/buffered/client.rb +109 -0
  17. data/lib/sidekiq/buffered/client_middleware.rb +38 -0
  18. data/lib/sidekiq/buffered/collector.rb +63 -0
  19. data/lib/sidekiq/buffered/config.rb +26 -0
  20. data/lib/sidekiq/buffered/dispatch.rb +95 -0
  21. data/lib/sidekiq/buffered/events.rb +29 -0
  22. data/lib/sidekiq/buffered/execution.rb +143 -0
  23. data/lib/sidekiq/buffered/heartbeat.rb +96 -0
  24. data/lib/sidekiq/buffered/inline.rb +17 -0
  25. data/lib/sidekiq/buffered/input.rb +122 -0
  26. data/lib/sidekiq/buffered/periodic_thread.rb +45 -0
  27. data/lib/sidekiq/buffered/poller.rb +81 -0
  28. data/lib/sidekiq/buffered/processor.rb +42 -0
  29. data/lib/sidekiq/buffered/registry.rb +38 -0
  30. data/lib/sidekiq/buffered/retry_callbacks.rb +32 -0
  31. data/lib/sidekiq/buffered/scheduled_input.rb +21 -0
  32. data/lib/sidekiq/buffered/scope.rb +17 -0
  33. data/lib/sidekiq/buffered/script.rb +48 -0
  34. data/lib/sidekiq/buffered/scripts/ack.lua +5 -0
  35. data/lib/sidekiq/buffered/scripts/append.lua +64 -0
  36. data/lib/sidekiq/buffered/scripts/claim.lua +29 -0
  37. data/lib/sidekiq/buffered/scripts/cleanup_failed.lua +7 -0
  38. data/lib/sidekiq/buffered/scripts/discard.lua +7 -0
  39. data/lib/sidekiq/buffered/scripts/exhaust.lua +10 -0
  40. data/lib/sidekiq/buffered/scripts/extend.lua +7 -0
  41. data/lib/sidekiq/buffered/scripts/flush_due.lua +25 -0
  42. data/lib/sidekiq/buffered/scripts/helpers/delete_batch.lua +21 -0
  43. data/lib/sidekiq/buffered/scripts/helpers/materialize_batch.lua +21 -0
  44. data/lib/sidekiq/buffered/scripts/helpers/redis_time.lua +6 -0
  45. data/lib/sidekiq/buffered/scripts/record_dispatch.lua +5 -0
  46. data/lib/sidekiq/buffered/scripts/recover.lua +21 -0
  47. data/lib/sidekiq/buffered/scripts/release.lua +8 -0
  48. data/lib/sidekiq/buffered/scripts/replay.lua +11 -0
  49. data/lib/sidekiq/buffered/scripts/retrying.lua +13 -0
  50. data/lib/sidekiq/buffered/scripts/tick.lua +10 -0
  51. data/lib/sidekiq/buffered/scripts.rb +23 -0
  52. data/lib/sidekiq/buffered/server_middleware.rb +51 -0
  53. data/lib/sidekiq/buffered/setter.rb +41 -0
  54. data/lib/sidekiq/buffered/statistics.rb +130 -0
  55. data/lib/sidekiq/buffered/store.rb +363 -0
  56. data/lib/sidekiq/buffered/testing.rb +65 -0
  57. data/lib/sidekiq/buffered/thread_local.rb +14 -0
  58. data/lib/sidekiq/buffered/version.rb +5 -0
  59. data/lib/sidekiq/buffered.rb +172 -0
  60. data/lib/sidekiq-buffered.rb +1 -0
  61. data/sidekiq-buffered.gemspec +37 -0
  62. 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