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