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,49 @@
|
|
|
1
|
+
module Sidekiq
|
|
2
|
+
module Buffered
|
|
3
|
+
class RetryEnqueueError < StandardError; end
|
|
4
|
+
|
|
5
|
+
module ActiveJobExecution
|
|
6
|
+
RETRY_EVENT = "enqueue_retry.active_job".freeze
|
|
7
|
+
HANDOFF_KEY = :sidekiq_buffered_active_job_handoffs
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
# Executes the batch as one Active Job. Active Job announces every
|
|
11
|
+
# retry_job through its public enqueue_retry instrumentation, and the
|
|
12
|
+
# buffered adapter records the retries Sidekiq accepted. A retry that
|
|
13
|
+
# was announced but never accepted (aborted callback, rejected push)
|
|
14
|
+
# must keep the source batch.
|
|
15
|
+
def perform(record, execution_jid)
|
|
16
|
+
data = record.items.first.active_job.merge(
|
|
17
|
+
"job_id" => record.id,
|
|
18
|
+
"provider_job_id" => execution_jid,
|
|
19
|
+
"arguments" => [record.arguments],
|
|
20
|
+
)
|
|
21
|
+
retried = false
|
|
22
|
+
observer = lambda do |_name, _start, _finish, _id, payload|
|
|
23
|
+
retried = true if payload[:job]&.job_id == record.id
|
|
24
|
+
end
|
|
25
|
+
handoffs = tracking_handoffs do
|
|
26
|
+
::ActiveSupport::Notifications.subscribed(observer, RETRY_EVENT) { ::ActiveJob::Base.execute(data) }
|
|
27
|
+
end
|
|
28
|
+
return unless retried && !handoffs.include?(record.id)
|
|
29
|
+
|
|
30
|
+
raise RetryEnqueueError, "Buffered batch retry was not enqueued"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Called by the queue adapter once Sidekiq accepted a retry payload.
|
|
34
|
+
def record_handoff(job_id)
|
|
35
|
+
Thread.current[HANDOFF_KEY]&.add(job_id)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def tracking_handoffs
|
|
41
|
+
ThreadLocal.with(HANDOFF_KEY, Set.new) do |handoffs|
|
|
42
|
+
yield
|
|
43
|
+
handoffs
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
module Sidekiq
|
|
2
|
+
module Buffered
|
|
3
|
+
# Keep whole-batch Active Job retries independent of Rails engine initialization.
|
|
4
|
+
class ActiveJobRetry
|
|
5
|
+
include Sidekiq::Job
|
|
6
|
+
|
|
7
|
+
def perform(data)
|
|
8
|
+
::ActiveJob::Base.execute(data.merge("provider_job_id" => jid))
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
require "forwardable"
|
|
2
|
+
|
|
3
|
+
module Sidekiq
|
|
4
|
+
module Buffered
|
|
5
|
+
# Inspection and manual intervention for one stored batch.
|
|
6
|
+
class Batch
|
|
7
|
+
extend Forwardable
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
def find(id)
|
|
11
|
+
record = Buffered.store.fetch(id)
|
|
12
|
+
new(record) if record
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def failed(limit: Store::PAGE_SIZE, offset: 0, job_class: nil, group: Buffered::ANY_GROUP)
|
|
16
|
+
raise ArgumentError, "limit must be positive" unless limit.is_a?(Integer) && limit.positive?
|
|
17
|
+
raise ArgumentError, "offset must be non-negative" unless offset.is_a?(Integer) && offset >= 0
|
|
18
|
+
|
|
19
|
+
scope = Scope.new(job_class:, group:)
|
|
20
|
+
matching = each_failed.lazy.select { |batch| scope.matches?(batch.job_class, batch.group) }
|
|
21
|
+
matching.drop(offset).take(limit).force
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def each_failed(&block)
|
|
25
|
+
return enum_for(__method__) unless block_given?
|
|
26
|
+
|
|
27
|
+
offset = 0
|
|
28
|
+
loop do
|
|
29
|
+
ids = Buffered.store.failed_ids(offset:, limit: Store::PAGE_SIZE)
|
|
30
|
+
ids.filter_map { |id| find(id) }.each(&block)
|
|
31
|
+
break if ids.size < Store::PAGE_SIZE
|
|
32
|
+
|
|
33
|
+
offset += ids.size
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def_delegators :@record, :id, :job_class, :group, :state, :attempts, :failed_at, :last_error, :exhausted?,
|
|
39
|
+
:input_jids
|
|
40
|
+
|
|
41
|
+
def initialize(record)
|
|
42
|
+
@record = record
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def items
|
|
46
|
+
@record.arguments
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Re-reads the batch. One that has since been performed and removed keeps
|
|
50
|
+
# its last known state.
|
|
51
|
+
def reload
|
|
52
|
+
record = Buffered.store.fetch(id)
|
|
53
|
+
@record = record if record
|
|
54
|
+
self
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def retry!
|
|
58
|
+
Buffered.store.replay(id)
|
|
59
|
+
reload
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# The batch no longer exists in Redis after this; the state is a local marker.
|
|
63
|
+
def discard!
|
|
64
|
+
Buffered.store.discard(id)
|
|
65
|
+
@record = @record.with(state: "discarded", exhausted: false)
|
|
66
|
+
self
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
module Sidekiq
|
|
2
|
+
module Buffered
|
|
3
|
+
# What a running batch exposes to +perform+ through +buffered_batch+.
|
|
4
|
+
class BatchContext
|
|
5
|
+
KEY = :sidekiq_buffered_batch
|
|
6
|
+
|
|
7
|
+
attr_reader :id, :job_class, :group, :input_jids, :attempts
|
|
8
|
+
|
|
9
|
+
def self.current
|
|
10
|
+
Thread.current[KEY]
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.with(record, &)
|
|
14
|
+
ThreadLocal.with(KEY, new(record), &)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def initialize(record)
|
|
18
|
+
@id = record.id.dup.freeze
|
|
19
|
+
@job_class = record.job_class.dup.freeze
|
|
20
|
+
@group = immutable(record.group)
|
|
21
|
+
@input_jids = record.input_jids.map { |jid| jid.dup.freeze }.freeze
|
|
22
|
+
@attempts = record.attempts
|
|
23
|
+
freeze
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def immutable(value)
|
|
29
|
+
case value
|
|
30
|
+
when Hash then value.each_value { |entry| immutable(entry) }
|
|
31
|
+
when Array then value.each { |entry| immutable(entry) }
|
|
32
|
+
end
|
|
33
|
+
value.freeze
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
module Sidekiq
|
|
2
|
+
module Buffered
|
|
3
|
+
# One input inside a stored batch.
|
|
4
|
+
Item = Data.define(:jid, :args, :context, :active_job, :buffered_at) do
|
|
5
|
+
def self.parse(raw)
|
|
6
|
+
data = Sidekiq.load_json(raw)
|
|
7
|
+
new(
|
|
8
|
+
jid: data.fetch("jid"),
|
|
9
|
+
args: data.fetch("args"),
|
|
10
|
+
context: data.fetch("context", {}),
|
|
11
|
+
active_job: data["active_job"],
|
|
12
|
+
buffered_at: data["buffered_at"],
|
|
13
|
+
)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def active_job?
|
|
17
|
+
!active_job.nil?
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# A batch as Redis holds it.
|
|
22
|
+
BatchRecord = Data.define(
|
|
23
|
+
:id, :job_class, :group, :items, :attempts, :lease_timeout, :state, :exhausted, :failed_at, :last_error,
|
|
24
|
+
) do
|
|
25
|
+
# The fields the claim script returns for a batch that is now running.
|
|
26
|
+
def self.claimed(id, values)
|
|
27
|
+
job_class, group, raw_items, attempts, lease_timeout = values
|
|
28
|
+
new(
|
|
29
|
+
id:, job_class:, group: Input.decode_group(group), items: parse_items(raw_items),
|
|
30
|
+
attempts: attempts.to_i, lease_timeout: lease_timeout.to_i, state: "running",
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# A full read of the batch hash plus its score in the failed index.
|
|
35
|
+
def self.stored(id, data, failed_at:)
|
|
36
|
+
exhausted = data["exhausted"] == "1"
|
|
37
|
+
new(
|
|
38
|
+
id:,
|
|
39
|
+
job_class: data.fetch("class"),
|
|
40
|
+
group: Input.decode_group(data["group"]),
|
|
41
|
+
items: parse_items(data.fetch("items")),
|
|
42
|
+
attempts: data.fetch("attempts", "0").to_i,
|
|
43
|
+
lease_timeout: data["lease_timeout"]&.to_i,
|
|
44
|
+
state: data["state"] || (exhausted ? "failed" : "queued"),
|
|
45
|
+
exhausted:,
|
|
46
|
+
failed_at: failed_at && Float(failed_at),
|
|
47
|
+
last_error: data["last_error"] && Sidekiq.load_json(data["last_error"]),
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.parse_items(raw)
|
|
52
|
+
Sidekiq.load_json(raw).map { |item| Item.parse(item) }
|
|
53
|
+
end
|
|
54
|
+
private_class_method :parse_items
|
|
55
|
+
|
|
56
|
+
def initialize(id:, job_class:, items:, group: nil, attempts: 0, lease_timeout: nil, state: "queued",
|
|
57
|
+
exhausted: false, failed_at: nil, last_error: nil)
|
|
58
|
+
super(id: id.to_s, job_class:, items:, group:, attempts:, lease_timeout:, state:, exhausted:, failed_at:,
|
|
59
|
+
last_error:)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def exhausted?
|
|
63
|
+
exhausted
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def active_job?
|
|
67
|
+
items.first.active_job?
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def input_jids
|
|
71
|
+
items.map(&:jid)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def arguments
|
|
75
|
+
items.map(&:args)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
require "sidekiq/client"
|
|
2
|
+
|
|
3
|
+
module Sidekiq
|
|
4
|
+
module Buffered
|
|
5
|
+
# Sidekiq::Client subclass used by buffered job classes. It admits every
|
|
6
|
+
# immediate input of a push_bulk chunk in one Redis script and returns
|
|
7
|
+
# input JIDs, which the plain Sidekiq::Client cannot do once the client
|
|
8
|
+
# middleware diverts a payload away from the queue.
|
|
9
|
+
class Client < Sidekiq::Client
|
|
10
|
+
def push(item)
|
|
11
|
+
return super unless Buffered.registered?(item["class"])
|
|
12
|
+
|
|
13
|
+
Collector.with(store) do |collector|
|
|
14
|
+
before = collector.size
|
|
15
|
+
super(item) || collector.jid_at(before)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def push_bulk(items)
|
|
20
|
+
return super unless Buffered.registered?(items["class"])
|
|
21
|
+
|
|
22
|
+
options = BulkOptions.new(items)
|
|
23
|
+
options.slices.flat_map { |slice| push_slice(items, slice) }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
# Each slice is one Redis script call. A slice with +at+ holds only
|
|
29
|
+
# scheduled inputs, which Sidekiq schedules individually.
|
|
30
|
+
def push_slice(items, slice)
|
|
31
|
+
Collector.with(store) { slice.map { |payload| push(items.merge(payload)) } }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def store
|
|
35
|
+
@store ||= Store.new(redis_pool)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Mirrors the bulk options Sidekiq::Client#push_bulk consumes and yields
|
|
39
|
+
# per-input payload overrides in batch_size slices.
|
|
40
|
+
class BulkOptions
|
|
41
|
+
DEFAULT_BATCH_SIZE = 1_000
|
|
42
|
+
MIN_SPREAD = 5
|
|
43
|
+
|
|
44
|
+
def initialize(items)
|
|
45
|
+
@args = items["args"]
|
|
46
|
+
raise ArgumentError, "Bulk arguments must be an Array of Arrays: [[1], [2]]" unless arrays?(@args)
|
|
47
|
+
|
|
48
|
+
@at = items.delete("at") || items.delete(:at)
|
|
49
|
+
@spread = items.delete(:spread_interval) || items.delete("spread_interval")
|
|
50
|
+
@jid = items.delete("jid")
|
|
51
|
+
@batch_size = items.delete(:batch_size) || items.delete("batch_size") || DEFAULT_BATCH_SIZE
|
|
52
|
+
validate_at!
|
|
53
|
+
validate_spread!
|
|
54
|
+
@at = spread_times if @spread
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def slices
|
|
58
|
+
overrides = @args.map.with_index do |job_args, index|
|
|
59
|
+
override = { "args" => job_args }
|
|
60
|
+
override["jid"] = @jid if @jid
|
|
61
|
+
override["at"] = @at.is_a?(Array) ? @at[index] : @at if @at
|
|
62
|
+
override
|
|
63
|
+
end
|
|
64
|
+
overrides.each_slice(@batch_size)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def arrays?(value)
|
|
70
|
+
value.is_a?(Array) && value.all?(Array)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def validate_at!
|
|
74
|
+
return validate_jid! unless @at
|
|
75
|
+
|
|
76
|
+
unless Array(@at).any? && Array(@at).all?(Numeric)
|
|
77
|
+
raise ArgumentError, "Job 'at' must be a Numeric or an Array of Numeric timestamps"
|
|
78
|
+
end
|
|
79
|
+
if @at.is_a?(Array) && @at.size != @args.size
|
|
80
|
+
raise ArgumentError, "Job 'at' Array must have same size as 'args' Array"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
validate_jid!
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def validate_jid!
|
|
87
|
+
return unless @jid && @args.size > 1
|
|
88
|
+
|
|
89
|
+
raise ArgumentError, "Explicitly passing 'jid' when pushing more than one job is not supported"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def validate_spread!
|
|
93
|
+
return unless @spread
|
|
94
|
+
|
|
95
|
+
unless @spread.is_a?(Numeric) && @spread.positive?
|
|
96
|
+
raise ArgumentError, "Jobs 'spread_interval' must be a positive Numeric"
|
|
97
|
+
end
|
|
98
|
+
raise ArgumentError, "Only one of 'at' or 'spread_interval' can be provided" if @at
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def spread_times
|
|
102
|
+
spread = [@spread, MIN_SPREAD].max
|
|
103
|
+
now = Time.now.to_f
|
|
104
|
+
Array.new(@args.size) { now + (rand * spread) }
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module Sidekiq
|
|
2
|
+
module Buffered
|
|
3
|
+
# Sidekiq client middleware that diverts buffered inputs into Redis buffers.
|
|
4
|
+
# It runs after the rest of the chain so buffered inputs capture the metadata
|
|
5
|
+
# other client middleware attach (CurrentAttributes, tags, trace fields).
|
|
6
|
+
class ClientMiddleware
|
|
7
|
+
include Sidekiq::ClientMiddleware
|
|
8
|
+
|
|
9
|
+
def call(_job_class, job, _queue, redis_pool)
|
|
10
|
+
return yield if Inline.active?
|
|
11
|
+
|
|
12
|
+
buffered = Buffered.registered?(job["class"])
|
|
13
|
+
Input.normalize!(job) if buffered
|
|
14
|
+
payload = yield
|
|
15
|
+
return payload unless payload
|
|
16
|
+
|
|
17
|
+
if buffered
|
|
18
|
+
admit(payload, redis_pool)
|
|
19
|
+
else
|
|
20
|
+
Dispatch.prepare_processor_payload(payload) if payload["class"] == Processor.name
|
|
21
|
+
payload
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def admit(payload, redis_pool)
|
|
28
|
+
# Fake mode keeps inputs in the job class's own testing array.
|
|
29
|
+
return payload if Testing.fake?
|
|
30
|
+
# Delay first, buffer second: Sidekiq schedules the admission job itself.
|
|
31
|
+
return ScheduledInput.wrap(payload) if payload.key?("at") && !Testing.inline?
|
|
32
|
+
|
|
33
|
+
Collector.admit(payload, Store.new(redis_pool))
|
|
34
|
+
false
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
module Sidekiq
|
|
2
|
+
module Buffered
|
|
3
|
+
# Thread-local collection point for buffered payloads admitted through one
|
|
4
|
+
# client call. The client middleware stashes payloads here instead of writing
|
|
5
|
+
# them one by one; the outermost collector writes them atomically.
|
|
6
|
+
class Collector
|
|
7
|
+
KEY = :sidekiq_buffered_collector
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
def current
|
|
11
|
+
Thread.current[KEY]
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def with(store)
|
|
15
|
+
return yield(current) if current
|
|
16
|
+
|
|
17
|
+
ThreadLocal.with(KEY, new(store)) do |collector|
|
|
18
|
+
result = yield(collector)
|
|
19
|
+
collector.write
|
|
20
|
+
result
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Middleware entry point: stash under an active collector, otherwise write now.
|
|
25
|
+
def admit(payload, store)
|
|
26
|
+
if current
|
|
27
|
+
current.stash(payload)
|
|
28
|
+
else
|
|
29
|
+
new(store).stash(payload).write
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def initialize(store)
|
|
35
|
+
@store = store
|
|
36
|
+
@payloads = []
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def size
|
|
40
|
+
@payloads.size
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def stash(payload)
|
|
44
|
+
@payloads << payload
|
|
45
|
+
self
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def jid_at(index)
|
|
49
|
+
@payloads[index]&.fetch("jid")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def write
|
|
53
|
+
return if @payloads.empty?
|
|
54
|
+
|
|
55
|
+
if Testing.inline?
|
|
56
|
+
Testing.inline(@payloads)
|
|
57
|
+
else
|
|
58
|
+
@store.append(@payloads)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
module Sidekiq
|
|
2
|
+
module Buffered
|
|
3
|
+
# The +buffered+ declaration of one job class.
|
|
4
|
+
Config = Data.define(:max_size, :max_wait, :group_by, :lease_timeout) do
|
|
5
|
+
def initialize(max_size:, max_wait:, group_by: nil, lease_timeout: nil)
|
|
6
|
+
raise ArgumentError, "max_size must be a positive Integer" unless positive_integer?(max_size)
|
|
7
|
+
|
|
8
|
+
wait = max_wait.to_f
|
|
9
|
+
raise ArgumentError, "max_wait must be positive" unless wait.positive?
|
|
10
|
+
|
|
11
|
+
unless lease_timeout.nil? || positive_integer?(lease_timeout)
|
|
12
|
+
raise ArgumentError, "lease_timeout must be a positive Integer or nil"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
key = group_by.to_s
|
|
16
|
+
super(max_size:, max_wait: wait, group_by: key.empty? ? nil : key, lease_timeout:)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def positive_integer?(value)
|
|
22
|
+
value.is_a?(Integer) && value.positive?
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
module Sidekiq
|
|
2
|
+
module Buffered
|
|
3
|
+
# Enqueues a Processor job for each materialized batch and records the exact
|
|
4
|
+
# queued payload, so recovery can check whether it still waits in its queue.
|
|
5
|
+
class Dispatch
|
|
6
|
+
KEY = :sidekiq_buffered_dispatch
|
|
7
|
+
# Fields Sidekiq's retry machinery adds to a processor payload.
|
|
8
|
+
PROCESSOR_FIELDS = %w[display_class buffered_context retry_count retried_at failed_at error_message
|
|
9
|
+
error_class error_backtrace discarded_at].freeze
|
|
10
|
+
|
|
11
|
+
Target = Data.define(:id, :job_class, :context)
|
|
12
|
+
|
|
13
|
+
# Called by the client middleware for every processor push, including
|
|
14
|
+
# Sidekiq's own retry and Dead set re-pushes. The stored batch context
|
|
15
|
+
# wins over whatever ambient metadata the client chain just attached.
|
|
16
|
+
# Mutates in place: Sidekiq pushes this same object.
|
|
17
|
+
def self.prepare_processor_payload(payload)
|
|
18
|
+
if payload.key?("buffered_context")
|
|
19
|
+
context = payload.fetch("buffered_context")
|
|
20
|
+
payload.select! { |key, _value| Input::PAYLOAD_FIELDS.include?(key) || PROCESSOR_FIELDS.include?(key) }
|
|
21
|
+
payload.merge!(context.except(*Input::EXECUTION_FIELDS))
|
|
22
|
+
end
|
|
23
|
+
Thread.current[KEY]&.push(payload)
|
|
24
|
+
payload
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def initialize(store)
|
|
28
|
+
@store = store
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def enqueue(ids)
|
|
32
|
+
targets = targets_for(ids)
|
|
33
|
+
return if targets.empty?
|
|
34
|
+
|
|
35
|
+
client = Sidekiq::Client.new(pool: @store.redis_pool)
|
|
36
|
+
queued = capture do
|
|
37
|
+
targets.group_by { |target| processor_options(target) }.each do |options, batch|
|
|
38
|
+
client.push_bulk(options.merge("class" => Processor, "args" => batch.map { |target| [target.id] }))
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
record(queued)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
# Dispatch needs only the class and the shared middleware context, not the
|
|
47
|
+
# item payloads. Batches written before the context field existed fall
|
|
48
|
+
# back to reading their first item.
|
|
49
|
+
def targets_for(ids)
|
|
50
|
+
return [] if ids.empty?
|
|
51
|
+
|
|
52
|
+
values = @store.redis_pool.with do |conn|
|
|
53
|
+
conn.pipelined do |pipeline|
|
|
54
|
+
ids.each { |id| pipeline.call("HMGET", @store.batch_key(id), "class", "dispatch_context") }
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
ids.zip(values).filter_map do |id, (job_class, context)|
|
|
58
|
+
Target.new(id:, job_class:, context: stored_context(id, context)) if job_class
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def stored_context(id, raw)
|
|
63
|
+
return Sidekiq.load_json(raw) if raw
|
|
64
|
+
|
|
65
|
+
record = @store.fetch(id)
|
|
66
|
+
record ? record.items.first.context : {}
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# +wrapped+ names the job class for Sidekiq's logs, metrics, and retry
|
|
70
|
+
# callbacks; +display_class+ does the same for Sidekiq Web.
|
|
71
|
+
def processor_options(target)
|
|
72
|
+
target.context.merge(
|
|
73
|
+
"wrapped" => target.job_class, "display_class" => target.job_class, "buffered_context" => target.context,
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The middleware hands back the payload objects Sidekiq pushes. Sidekiq
|
|
78
|
+
# stamps enqueued_at on those same objects, so serializing them after the
|
|
79
|
+
# push reproduces the queue entry recovery looks for with LPOS.
|
|
80
|
+
def capture
|
|
81
|
+
ThreadLocal.with(KEY, []) do |captured|
|
|
82
|
+
yield
|
|
83
|
+
captured
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def record(payloads)
|
|
88
|
+
calls = payloads.reject { |payload| payload.key?("at") }.map do |payload|
|
|
89
|
+
[[@store.batch_key(payload["args"].first)], ["queue:#{payload['queue']}", Sidekiq.dump_json(payload)]]
|
|
90
|
+
end
|
|
91
|
+
@store.redis_pool.with { |conn| Scripts::RECORD_DISPATCH.call_each(conn, calls) }
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
module Sidekiq
|
|
2
|
+
module Buffered
|
|
3
|
+
class << self
|
|
4
|
+
attr_reader :on_event
|
|
5
|
+
|
|
6
|
+
def on_event=(handler)
|
|
7
|
+
raise ArgumentError, "on_event must respond to call or be nil" unless handler.nil? || handler.respond_to?(:call)
|
|
8
|
+
|
|
9
|
+
@on_event = handler
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def notify(event, **details)
|
|
13
|
+
@on_event&.call(event, event_snapshot(details))
|
|
14
|
+
rescue StandardError => e
|
|
15
|
+
Sidekiq.logger.warn("sidekiq-buffered event handler failed: #{e.class}: #{e.message}")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def event_snapshot(value)
|
|
19
|
+
case value
|
|
20
|
+
when Hash then value.transform_values { |item| event_snapshot(item) }.freeze
|
|
21
|
+
when Array then value.map { |item| event_snapshot(item) }.freeze
|
|
22
|
+
when String then value.dup.freeze
|
|
23
|
+
else value
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
private :event_snapshot
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|