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,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