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,363 @@
1
+ require "digest"
2
+ require_relative "scripts"
3
+
4
+ module Sidekiq
5
+ module Buffered
6
+ # Redis persistence for buffers and batches. Every mutation is one Lua
7
+ # script, so concurrent producers, pollers, and workers never see a batch
8
+ # half-written.
9
+ class Store
10
+ PREFIX = "sidekiq-buffered:v1".freeze
11
+ DEADLINES = "#{PREFIX}:deadlines".freeze
12
+ BATCH_SEQ = "#{PREFIX}:batch-seq".freeze
13
+ BATCH_PREFIX = "#{PREFIX}:batch:".freeze
14
+ LEASE_PREFIX = "#{PREFIX}:lease:".freeze
15
+ META = "#{PREFIX}:meta".freeze
16
+ OUTSTANDING = "#{PREFIX}:outstanding".freeze
17
+ FAILED = "#{PREFIX}:failed".freeze
18
+ DEFAULT_CONTEXT = { "queue" => "default", "retry" => true }.freeze
19
+ PAGE_SIZE = 100
20
+ MAX_ERROR_MESSAGE_BYTES = 2048
21
+
22
+ Due = Struct.new(:now, :buffers, :outstanding, :next_at)
23
+
24
+ attr_reader :redis_pool
25
+
26
+ # Redis is the clock unless +clock+ (a callable returning Unix seconds) is
27
+ # given, so skewed producer and worker clocks cannot disagree about deadlines.
28
+ def initialize(redis_pool, clock: nil)
29
+ @redis_pool = redis_pool
30
+ @clock = clock
31
+ end
32
+
33
+ def now
34
+ return @clock.call if @clock
35
+
36
+ seconds, microseconds = redis { |conn| conn.call("TIME") }
37
+ seconds.to_i + (microseconds.to_i / 1_000_000.0)
38
+ end
39
+
40
+ def batch_key(batch_id)
41
+ "#{BATCH_PREFIX}#{batch_id}"
42
+ end
43
+
44
+ def lease_key(batch_id)
45
+ "#{LEASE_PREFIX}#{batch_id}"
46
+ end
47
+
48
+ # Admission is one script call. Redis stamps the time itself unless a clock is pinned.
49
+ def append(payloads, now: nil)
50
+ groups = payloads.map { |payload| Input.new(payload) }.group_by(&:identity).map do |identity, inputs|
51
+ keys, args = append_call(identity, inputs)
52
+ { keys:, args: }
53
+ end
54
+ keys = groups.flat_map { |group| group[:keys] }.uniq
55
+ ids = redis { |conn| Scripts::APPEND.call(conn, keys:, args: [Sidekiq.dump_json(groups), pinned(now)]) }
56
+ dispatch(ids)
57
+ Buffered.notify(:appended, input_count: payloads.size, batch_ids: ids)
58
+ ids
59
+ end
60
+
61
+ def flush(scope = Scope.new)
62
+ at = now
63
+ ids = []
64
+ each_buffer do |member, meta|
65
+ next unless scope.matches?(meta["class"], Input.decode_group(meta["group"]))
66
+
67
+ keys, args = flush_call(member, now: at, force: true)
68
+ ids.concat(redis { |conn| Scripts::FLUSH_DUE.call(conn, keys:, args:) } || [])
69
+ end
70
+ dispatch(ids)
71
+ ids
72
+ end
73
+
74
+ def each_buffer
75
+ cursor = "0"
76
+ loop do
77
+ cursor, entries = redis { |conn| conn.call("ZSCAN", DEADLINES, cursor, "COUNT", PAGE_SIZE) }
78
+ entries.each_slice(2) do |member, _score|
79
+ raw = redis { |conn| conn.call("HGET", META, member) }
80
+ yield member, Sidekiq.load_json(raw) if raw
81
+ end
82
+ break if cursor == "0"
83
+ end
84
+ end
85
+
86
+ # Snapshot of everything the poller acts on, in one round trip.
87
+ def due(now: nil, limit: 500)
88
+ values = redis { |conn| Scripts::TICK.call(conn, keys: [DEADLINES, OUTSTANDING], args: [pinned(now), limit]) }
89
+ upcoming = values.values_at(3, 4).filter_map { |raw| Float(raw) unless raw.nil? || raw.empty? }
90
+ Due.new(Float(values.fetch(0)), values.fetch(1), values.fetch(2), upcoming.min)
91
+ end
92
+
93
+ def flush_due(now: nil, limit: 500)
94
+ snapshot = due(now:, limit:)
95
+ flush_members(snapshot.buffers, now: snapshot.now)
96
+ end
97
+
98
+ def flush_members(members, now:)
99
+ return [] if members.empty?
100
+
101
+ results = redis { |conn| Scripts::FLUSH_DUE.call_each(conn, members.map { |member| flush_call(member, now:) }) }
102
+ ids = results.flat_map { |flushed| Array(flushed) }
103
+ dispatch(ids)
104
+ ids
105
+ end
106
+
107
+ def recover_outstanding(now: nil, limit: 500)
108
+ snapshot = due(now:, limit:)
109
+ recover(snapshot.outstanding, now: snapshot.now)
110
+ end
111
+
112
+ def recover(ids, now:)
113
+ return [] if ids.empty?
114
+
115
+ recover_at = now + Buffered.recovery_interval
116
+ results = redis do |conn|
117
+ calls = ids.map do |id|
118
+ [[OUTSTANDING, lease_key(id), batch_key(id)], [id, now, recover_at, Buffered.recovery_max_interval]]
119
+ end
120
+ Scripts::RECOVER.call_each(conn, calls)
121
+ end
122
+ reserved = ids.zip(results).filter_map { |id, result| id if result == 1 }
123
+ return reserved if reserved.empty?
124
+
125
+ dispatch(reserved)
126
+ Buffered.notify(:recovered, batch_ids: reserved)
127
+ Sidekiq.logger.info("sidekiq-buffered recovered batches: #{reserved.join(', ')}")
128
+ reserved
129
+ end
130
+
131
+ def claim(batch_id, token:, lease_token: token, now: nil)
132
+ now ||= self.now
133
+ values = redis do |conn|
134
+ Scripts::CLAIM.call(
135
+ conn,
136
+ keys: batch_keys(batch_id),
137
+ args: [token.to_s, Buffered.lease_timeout, now, batch_id, lease_token.to_s],
138
+ )
139
+ end
140
+ unless values
141
+ Buffered.notify(:claim_skipped, batch_id:)
142
+ Sidekiq.logger.debug { "sidekiq-buffered claim skipped: batch=#{batch_id}" }
143
+ return
144
+ end
145
+
146
+ record = BatchRecord.claimed(batch_id, values)
147
+ Buffered.notify(:claimed, batch_id:, attempts: record.attempts)
148
+ record
149
+ end
150
+
151
+ # Extends every lease still owned by its token and returns the leases that
152
+ # could not be extended.
153
+ def extend_leases(leases, now: nil)
154
+ now ||= self.now
155
+ results = redis do |conn|
156
+ calls = leases.map do |lease|
157
+ [
158
+ [lease_key(lease.batch_id), OUTSTANDING],
159
+ [lease.token, lease.timeout, now + lease.timeout, lease.batch_id],
160
+ ]
161
+ end
162
+ Scripts::EXTEND.call_each(conn, calls)
163
+ end
164
+ leases.zip(results).filter_map { |lease, extended| lease if extended.to_i.zero? }
165
+ end
166
+
167
+ def release(batch_id, token:, now: nil)
168
+ now ||= self.now
169
+ released = redis do |conn|
170
+ Scripts::RELEASE.call(
171
+ conn,
172
+ keys: [batch_key(batch_id), lease_key(batch_id), OUTSTANDING],
173
+ args: [token.to_s, now + Buffered.recovery_interval, batch_id],
174
+ )
175
+ end
176
+ Buffered.notify(:released, batch_id:) if released == 1
177
+ released
178
+ end
179
+
180
+ def ack(batch_id, token:)
181
+ acknowledged = redis { |conn| Scripts::ACK.call(conn, keys: batch_keys(batch_id), args: [token.to_s, batch_id]) }
182
+ Sidekiq.logger.warn("sidekiq-buffered acknowledgement lost: batch=#{batch_id}") if acknowledged.zero?
183
+ Buffered.notify(acknowledged == 1 ? :acknowledged : :acknowledgement_lost, batch_id:)
184
+ acknowledged
185
+ end
186
+
187
+ # Protects recovery through the latest possible retry time plus the
188
+ # batch's lease timeout. The original Sidekiq JID can reclaim regardless.
189
+ def mark_retrying(batch_id, token:, exception:, delay:, now: nil)
190
+ now ||= self.now
191
+ updated = redis do |conn|
192
+ Scripts::RETRYING.call(
193
+ conn,
194
+ keys: [batch_key(batch_id), lease_key(batch_id), OUTSTANDING],
195
+ args: [batch_id, token, delay, now, Buffered.lease_timeout, error_details(exception, now)],
196
+ )
197
+ end
198
+ Buffered.notify(:retrying, batch_id:) if updated == 1
199
+ updated
200
+ end
201
+
202
+ def exhaust(batch_id, token: nil, exception: nil, now: nil)
203
+ now ||= self.now
204
+ updated = redis do |conn|
205
+ Scripts::EXHAUST.call(
206
+ conn,
207
+ keys: batch_keys(batch_id),
208
+ args: [batch_id, now, token.to_s, exception ? error_details(exception, now) : ""],
209
+ )
210
+ end
211
+ Buffered.notify(:failed, batch_id:) if updated == 1
212
+ updated
213
+ end
214
+
215
+ def fetch(batch_id)
216
+ redis do |conn|
217
+ data = conn.hgetall(batch_key(batch_id))
218
+ next if data.nil? || data.empty?
219
+
220
+ BatchRecord.stored(batch_id, data, failed_at: conn.zscore(FAILED, batch_id))
221
+ end
222
+ end
223
+
224
+ def failed_ids(limit: PAGE_SIZE, offset: 0)
225
+ redis { |conn| conn.call("ZRANGE", FAILED, offset, offset + limit - 1) }
226
+ end
227
+
228
+ def replay(batch_id, now: nil)
229
+ now ||= self.now
230
+ result = redis do |conn|
231
+ Scripts::REPLAY.call(conn, keys: batch_keys(batch_id), args: [batch_id, now + Buffered.recovery_interval])
232
+ end
233
+ remove_dead_entry(exhausted_result!(batch_id, result))
234
+ dispatch([batch_id])
235
+ batch_id
236
+ end
237
+
238
+ def discard(batch_id)
239
+ result = redis { |conn| Scripts::DISCARD.call(conn, keys: batch_keys(batch_id), args: [batch_id]) }
240
+ remove_dead_entry(exhausted_result!(batch_id, result))
241
+ Buffered.notify(:discarded, batch_id:)
242
+ batch_id
243
+ end
244
+
245
+ def cleanup_failed(now: nil, limit: PAGE_SIZE)
246
+ retention = Buffered.failed_retention
247
+ return [] if retention.nil?
248
+
249
+ cutoff = (now || self.now) - retention
250
+ redis do |conn|
251
+ Scripts::CLEANUP_FAILED.call(
252
+ conn,
253
+ keys: [FAILED, OUTSTANDING],
254
+ args: [BATCH_PREFIX, LEASE_PREFIX, cutoff, limit],
255
+ )
256
+ end || []
257
+ end
258
+
259
+ private
260
+
261
+ def redis(&)
262
+ @redis_pool.with(&)
263
+ end
264
+
265
+ def dispatch(ids)
266
+ Dispatch.new(self).enqueue(ids)
267
+ end
268
+
269
+ # The clock value handed to scripts that can read Redis TIME themselves:
270
+ # empty unless the caller or an injected clock pins it.
271
+ def pinned(now)
272
+ now ||= @clock&.call
273
+ now.nil? ? "" : format("%.6f", now)
274
+ end
275
+
276
+ # The KEYS most lifecycle scripts operate on.
277
+ def batch_keys(batch_id)
278
+ [batch_key(batch_id), lease_key(batch_id), OUTSTANDING, FAILED]
279
+ end
280
+
281
+ # Replay and discard return {1, previous_jid} on success, 0 when the batch
282
+ # is not exhausted, and -1 when it does not exist.
283
+ def exhausted_result!(batch_id, result)
284
+ status, previous_jid = Array(result)
285
+ case status.to_i
286
+ when 1 then previous_jid
287
+ when 0 then raise ArgumentError, "Buffered batch #{batch_id} is not exhausted"
288
+ else raise ArgumentError, "Buffered batch #{batch_id} was not found"
289
+ end
290
+ end
291
+
292
+ def remove_dead_entry(jid)
293
+ return if jid.nil? || jid.empty?
294
+
295
+ redis do |conn|
296
+ cursor = "0"
297
+ loop do
298
+ cursor, entries = conn.call("ZSCAN", "dead", cursor, "MATCH", "*\"jid\":\"#{jid}\"*", "COUNT", PAGE_SIZE)
299
+ values = entries.each_slice(2).map(&:first)
300
+ conn.call("ZREM", "dead", *values) unless values.empty?
301
+ break if cursor == "0"
302
+ end
303
+ end
304
+ end
305
+
306
+ def append_call(identity, inputs)
307
+ config = identity.config
308
+ member = buffer_key(identity.job_class, identity.group, identity.context)
309
+ [
310
+ [member, DEADLINES, BATCH_SEQ, META, OUTSTANDING],
311
+ [
312
+ identity.job_class, identity.group, config.max_size, config.max_wait, member, BATCH_PREFIX,
313
+ buffer_meta(identity), Buffered.recovery_interval, *inputs.map(&:dump),
314
+ ],
315
+ ]
316
+ end
317
+
318
+ # +context+ is stored as a JSON string so Lua copies it verbatim into each
319
+ # batch; every input in a buffer shares the same middleware context.
320
+ def buffer_meta(identity)
321
+ Sidekiq.dump_json(
322
+ "class" => identity.job_class,
323
+ "group" => identity.group,
324
+ "id_prefix" => SecureRandom.uuid,
325
+ "capacity_key" => buffer_key(identity.job_class, identity.group),
326
+ "max_size" => identity.config.max_size,
327
+ "lease_timeout" => identity.config.lease_timeout || 0,
328
+ "context" => Sidekiq.dump_json(identity.context),
329
+ )
330
+ end
331
+
332
+ def flush_call(member, now:, force: false)
333
+ [
334
+ [member, DEADLINES, BATCH_SEQ, META, OUTSTANDING],
335
+ [member, now, BATCH_PREFIX, now + Buffered.recovery_interval, SecureRandom.uuid, force ? "1" : "0"],
336
+ ]
337
+ end
338
+
339
+ def error_details(exception, now)
340
+ message = exception.message.to_s.encode("UTF-8", invalid: :replace, undef: :replace)
341
+ Sidekiq.dump_json(
342
+ "class" => exception.class.name,
343
+ "message" => message.byteslice(0, MAX_ERROR_MESSAGE_BYTES).scrub(""),
344
+ "at" => now,
345
+ )
346
+ end
347
+
348
+ def canonical(value)
349
+ case value
350
+ when Hash then value.sort.to_h.transform_values { |entry| canonical(entry) }
351
+ when Array then value.map { |entry| canonical(entry) }
352
+ else value
353
+ end
354
+ end
355
+
356
+ def buffer_key(job_class, group, context = {})
357
+ context = context.reject { |key, value| DEFAULT_CONTEXT.key?(key) && DEFAULT_CONTEXT[key] == value }
358
+ group = Sidekiq.dump_json([group, canonical(context)]) unless context.empty?
359
+ "#{PREFIX}:buffer:#{job_class}:#{Digest::SHA256.hexdigest(group)}"
360
+ end
361
+ end
362
+ end
363
+ end
@@ -0,0 +1,65 @@
1
+ module Sidekiq
2
+ module Buffered
3
+ # Sidekiq::Testing support: fake mode keeps inputs in the job class's jobs
4
+ # array and drains them in batches; inline mode performs partial batches
5
+ # immediately. Neither touches Redis.
6
+ module Testing
7
+ class << self
8
+ def enabled?
9
+ defined?(Sidekiq::Testing) && Sidekiq::Testing.enabled?
10
+ end
11
+
12
+ def fake?
13
+ enabled? && Sidekiq::Testing.fake?
14
+ end
15
+
16
+ def inline?
17
+ enabled? && Sidekiq::Testing.inline?
18
+ end
19
+
20
+ def inline(payloads)
21
+ payloads.map { |payload| Input.new(payload) }.group_by(&:identity).each do |identity, inputs|
22
+ inputs.each_slice(identity.config.max_size) { |batch| execute(batch) }
23
+ end
24
+ end
25
+
26
+ def drain(klass)
27
+ perform_one(klass) until Sidekiq::Queues.jobs_by_class[klass.name].empty?
28
+ end
29
+
30
+ def perform_one(klass)
31
+ payloads = Sidekiq::Queues.jobs_by_class[klass.name]
32
+ raise Sidekiq::EmptyQueueError, "perform_one called with no jobs available" if payloads.empty?
33
+
34
+ inputs = payloads.map { |payload| Input.new(payload) }
35
+ identity = inputs.first.identity
36
+ batch = inputs.select { |input| input.identity == identity }.first(identity.config.max_size)
37
+ batch.each { |input| Sidekiq::Queues.delete_for(input.jid, input.payload["queue"], klass.name) }
38
+ execute(batch)
39
+ end
40
+
41
+ private
42
+
43
+ def execute(inputs)
44
+ first = inputs.first
45
+ record = BatchRecord.new(
46
+ id: SecureRandom.uuid,
47
+ job_class: first.job_class,
48
+ group: Input.decode_group(first.group),
49
+ items: inputs.map { |input| Item.parse(input.dump) },
50
+ attempts: 1,
51
+ )
52
+ BatchContext.with(record) { perform(record, first) }
53
+ end
54
+
55
+ def perform(record, first)
56
+ if record.active_job?
57
+ ActiveJobExecution.perform(record, first.jid)
58
+ else
59
+ Object.const_get(record.job_class).process_job(first.payload.merge("args" => [record.arguments]))
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,14 @@
1
+ module Sidekiq
2
+ module Buffered
3
+ # Sets a thread-local for the duration of a block, then restores what was there.
4
+ module ThreadLocal
5
+ def self.with(key, value)
6
+ previous = Thread.current[key]
7
+ Thread.current[key] = value
8
+ yield value
9
+ ensure
10
+ Thread.current[key] = previous
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,5 @@
1
+ module Sidekiq
2
+ module Buffered
3
+ VERSION = "0.1.0".freeze
4
+ end
5
+ end
@@ -0,0 +1,172 @@
1
+ require "sidekiq"
2
+ require_relative "buffered/version"
3
+ require_relative "buffered/config"
4
+ require_relative "buffered/registry"
5
+ require_relative "buffered/scope"
6
+ require_relative "buffered/thread_local"
7
+ require_relative "buffered/input"
8
+ require_relative "buffered/batch_record"
9
+ require_relative "buffered/store"
10
+ require_relative "buffered/dispatch"
11
+ require_relative "buffered/collector"
12
+ require_relative "buffered/inline"
13
+ require_relative "buffered/client"
14
+ require_relative "buffered/client_middleware"
15
+ require_relative "buffered/setter"
16
+ require_relative "buffered/testing"
17
+ require_relative "buffered/scheduled_input"
18
+ require_relative "buffered/periodic_thread"
19
+ require_relative "buffered/poller"
20
+ require_relative "buffered/heartbeat"
21
+ require_relative "buffered/execution"
22
+ require_relative "buffered/retry_callbacks"
23
+ require_relative "buffered/processor"
24
+ require_relative "buffered/server_middleware"
25
+ require_relative "buffered/batch"
26
+ require_relative "buffered/batch_context"
27
+ require_relative "buffered/statistics"
28
+ require_relative "buffered/events"
29
+
30
+ module Sidekiq
31
+ module Buffered
32
+ ANY_GROUP = Object.new.freeze
33
+ DEFAULT_POLL_INTERVAL = 0.25
34
+ DEFAULT_RECOVERY_INTERVAL = 30
35
+ DEFAULT_RECOVERY_MAX_INTERVAL = 300
36
+ DEFAULT_LEASE_TIMEOUT = 60
37
+ DEFAULT_FAILED_RETENTION = nil
38
+
39
+ @registry = Registry.new
40
+ @poll_interval = DEFAULT_POLL_INTERVAL
41
+ @recovery_interval = DEFAULT_RECOVERY_INTERVAL
42
+ @recovery_max_interval = DEFAULT_RECOVERY_MAX_INTERVAL
43
+ @lease_timeout = DEFAULT_LEASE_TIMEOUT
44
+ @failed_retention = DEFAULT_FAILED_RETENTION
45
+
46
+ class << self
47
+ attr_reader :registry, :poll_interval, :lease_timeout, :failed_retention, :recovery_interval,
48
+ :recovery_max_interval
49
+
50
+ def current_batch
51
+ BatchContext.current
52
+ end
53
+
54
+ # A store on Sidekiq's current default Redis pool.
55
+ def store
56
+ Store.new(Sidekiq.redis_pool)
57
+ end
58
+
59
+ def flush(job_class: nil, group: ANY_GROUP)
60
+ store.flush(Scope.new(job_class:, group:))
61
+ end
62
+
63
+ def stats(**)
64
+ Statistics.new(**).call
65
+ end
66
+
67
+ def included(base)
68
+ base.extend(ClassMethods)
69
+ end
70
+
71
+ def poll_interval=(value)
72
+ interval = value.to_f
73
+ raise ArgumentError, "poll_interval must be positive" unless interval.positive?
74
+
75
+ @poll_interval = interval
76
+ end
77
+
78
+ def recovery_interval=(value)
79
+ @recovery_interval = positive_integer(value, "recovery_interval")
80
+ end
81
+
82
+ def recovery_max_interval=(value)
83
+ @recovery_max_interval = positive_integer(value, "recovery_max_interval")
84
+ end
85
+
86
+ def lease_timeout=(value)
87
+ @lease_timeout = positive_integer(value, "lease_timeout")
88
+ end
89
+
90
+ def failed_retention=(value)
91
+ @failed_retention = positive_integer(value, "failed_retention", nullable: true)
92
+ end
93
+
94
+ def registered?(klass)
95
+ registry.registered?(klass)
96
+ end
97
+
98
+ def config_for(klass)
99
+ registry.fetch(klass)
100
+ end
101
+
102
+ private
103
+
104
+ def positive_integer(value, name, nullable: false)
105
+ return value if value.is_a?(Integer) && value.positive?
106
+ return if nullable && value.nil?
107
+
108
+ raise ArgumentError, "#{name} must be a positive Integer#{' or nil' if nullable}"
109
+ end
110
+ end
111
+
112
+ def buffered_batch
113
+ Buffered.current_batch
114
+ end
115
+
116
+ module ClassMethods
117
+ def buffered(max_size:, max_wait:, group_by: nil, lease_timeout: nil)
118
+ Buffered.registry.register(self, Config.new(max_size:, max_wait:, group_by:, lease_timeout:))
119
+ singleton_class.prepend(RetryCallbacks)
120
+ install_native_job_support if include?(Sidekiq::Job)
121
+ end
122
+
123
+ def flush(group: ANY_GROUP)
124
+ Buffered.flush(job_class: self, group:)
125
+ end
126
+
127
+ def drain
128
+ return super unless Buffered.registered?(self) && Testing.enabled?
129
+
130
+ Testing.drain(self)
131
+ end
132
+
133
+ def perform_one
134
+ return super unless Buffered.registered?(self) && Testing.enabled?
135
+
136
+ Testing.perform_one(self)
137
+ end
138
+
139
+ def sidekiq_buffered_config
140
+ return unless Buffered.registered?(self)
141
+
142
+ Buffered.config_for(self)
143
+ end
144
+
145
+ private
146
+
147
+ # Buffered pushes go through Sidekiq::Buffered::Client so callers keep
148
+ # receiving input JIDs and bulk chunks are admitted atomically.
149
+ def install_native_job_support
150
+ sidekiq_options("client_class" => Client)
151
+ extend(NativeJobMethods) unless singleton_class.include?(NativeJobMethods)
152
+ end
153
+ end
154
+ end
155
+ end
156
+
157
+ Sidekiq.default_configuration.client_middleware do |chain|
158
+ chain.prepend(Sidekiq::Buffered::ClientMiddleware)
159
+ end
160
+
161
+ Sidekiq.configure_server do |config|
162
+ config.server_middleware { |chain| chain.prepend(Sidekiq::Buffered::ServerMiddleware) }
163
+ config.on(:startup) do
164
+ Sidekiq::Buffered::Poller.start
165
+ Sidekiq::Buffered::Heartbeat.start
166
+ end
167
+ config.on(:quiet) { Sidekiq::Buffered::Poller.stop }
168
+ config.on(:shutdown) do
169
+ Sidekiq::Buffered::Poller.stop
170
+ Sidekiq::Buffered::Heartbeat.stop
171
+ end
172
+ end
@@ -0,0 +1 @@
1
+ require_relative "sidekiq/buffered"
@@ -0,0 +1,37 @@
1
+ require_relative "lib/sidekiq/buffered/version"
2
+
3
+ Gem::Specification.new do |spec|
4
+ spec.name = "sidekiq-buffered"
5
+ spec.version = Sidekiq::Buffered::VERSION
6
+ spec.authors = ["Sarunas Kujalis"]
7
+ spec.email = ["sarunas@kujalis.lt"]
8
+
9
+ spec.summary = "Buffer Sidekiq job inputs and perform them in batches by size or wait time."
10
+ spec.description = <<~DESC
11
+ Declares a Sidekiq job as buffered, keeps ordinary enqueue APIs, and later
12
+ invokes perform with an array of the original argument hashes.
13
+ DESC
14
+ spec.homepage = "https://github.com/ksarunas/sidekiq-buffered"
15
+ spec.license = "MIT"
16
+ spec.required_ruby_version = ">= 3.2.0"
17
+
18
+ spec.metadata["homepage_uri"] = spec.homepage
19
+ spec.metadata["source_code_uri"] = spec.homepage
20
+ spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
21
+ spec.metadata["rubygems_mfa_required"] = "true"
22
+
23
+ spec.files = Dir.chdir(__dir__) do
24
+ `git ls-files -z`.split("\x0").reject do |f|
25
+ f.start_with?("bin/", "spec/", "features/", ".git")
26
+ end
27
+ end
28
+ spec.require_paths = ["lib"]
29
+
30
+ spec.add_dependency "sidekiq", ">= 7.0", "< 9"
31
+ spec.add_development_dependency "activejob", ">= 7.0", "< 9"
32
+ spec.add_development_dependency "rake", "~> 13.0"
33
+ spec.add_development_dependency "rspec", "~> 3.13"
34
+ spec.add_development_dependency "rubocop", "~> 1.81"
35
+ spec.add_development_dependency "rubocop-performance", "~> 1.25"
36
+ spec.add_development_dependency "rubocop-rspec", "~> 3.6"
37
+ end