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,48 @@
1
+ require "digest/sha1"
2
+
3
+ module Sidekiq
4
+ module Buffered
5
+ # One Lua script from scripts/, with the shared helper functions it uses
6
+ # prepended. Calls go through EVALSHA and fall back to EVAL when Redis has
7
+ # not seen the script yet.
8
+ class Script
9
+ DIRECTORY = File.expand_path("scripts", __dir__)
10
+
11
+ def self.load(name, helpers: [])
12
+ sources = helpers.map { |helper| read("helpers/#{helper}") } << read(name)
13
+ new(sources.join("\n"))
14
+ end
15
+
16
+ def self.read(name)
17
+ File.read(File.join(DIRECTORY, "#{name}.lua"))
18
+ end
19
+ private_class_method :read
20
+
21
+ attr_reader :source, :sha
22
+
23
+ def initialize(source)
24
+ @source = source.freeze
25
+ @sha = Digest::SHA1.hexdigest(source).freeze
26
+ end
27
+
28
+ def call(conn, keys: [], args: [])
29
+ conn.call("EVALSHA", sha, keys.size, *keys, *args)
30
+ rescue RedisClient::CommandError => e
31
+ raise unless e.message.start_with?("NOSCRIPT")
32
+
33
+ conn.call("EVAL", source, keys.size, *keys, *args)
34
+ end
35
+
36
+ # Runs the script once per [keys, args] pair in a single pipeline. SCRIPT
37
+ # LOAD is idempotent, so loading first avoids NOSCRIPT handling mid-pipeline.
38
+ def call_each(conn, calls)
39
+ return [] if calls.empty?
40
+
41
+ conn.call("SCRIPT", "LOAD", source)
42
+ conn.pipelined do |pipeline|
43
+ calls.each { |keys, args| pipeline.call("EVALSHA", sha, keys.size, *keys, *args) }
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,5 @@
1
+ local batch_key, lease_key, outstanding_key, failed_key = unpack(KEYS)
2
+ local token, batch_id = unpack(ARGV)
3
+ if redis.call('GET', lease_key) ~= token then return 0 end
4
+ delete_batch(batch_key, lease_key, batch_id, outstanding_key, failed_key)
5
+ return 1
@@ -0,0 +1,64 @@
1
+ -- ARGV[1] is a JSON array of { keys, args } groups; ARGV[2] pins the clock or is ''.
2
+ local now_text = redis_time(ARGV[2])
3
+ local now = tonumber(now_text)
4
+
5
+ -- Appends one group's items to its buffer and materializes every full batch.
6
+ local function append_group(keys, args)
7
+ local buffer_key, deadlines_key, batch_seq_key, meta_key, outstanding_key = unpack(keys)
8
+ local job_class = args[1]
9
+ local group = args[2]
10
+ local max_size = tonumber(args[3])
11
+ local deadline_at = string.format('%.6f', now + tonumber(args[4]))
12
+ local member = args[5]
13
+ local batch_prefix = args[6]
14
+ local meta = args[7]
15
+ local recover_at = string.format('%.6f', now + tonumber(args[8]))
16
+ local added = #args - 8
17
+ -- Items arrive as JSON objects; stamp the Redis admission time into each.
18
+ local stamp = ',"buffered_at":' .. now_text .. '}'
19
+ local incoming = {}
20
+ local add_bytes = 0
21
+ for i = 9, #args do
22
+ local item = string.sub(args[i], 1, -2) .. stamp
23
+ incoming[#incoming + 1] = item
24
+ add_bytes = add_bytes + #item
25
+ end
26
+ local decoded_meta = cjson.decode(meta)
27
+ local capacity_key = decoded_meta.capacity_key or member
28
+ -- Per-group counters back Sidekiq::Buffered.stats; they are not caps.
29
+ local count_key = capacity_key .. ':count'
30
+ local bytes_key = capacity_key .. ':bytes'
31
+ if added > 0 then
32
+ local was_empty = redis.call('LLEN', buffer_key) == 0
33
+ for first = 1, #incoming, 1000 do
34
+ redis.call('RPUSH', buffer_key, unpack(incoming, first, math.min(first + 999, #incoming)))
35
+ end
36
+ redis.call('HSET', meta_key, member, meta)
37
+ redis.call('INCRBY', count_key, added)
38
+ redis.call('INCRBY', bytes_key, add_bytes)
39
+ if was_empty then
40
+ redis.call('ZADD', deadlines_key, deadline_at, member)
41
+ end
42
+ end
43
+ local batch = {
44
+ class = job_class, group = group, max_size = max_size, id_prefix = decoded_meta.id_prefix,
45
+ prefix = batch_prefix, seq_key = batch_seq_key, outstanding_key = outstanding_key,
46
+ recover_at = recover_at, lease_timeout = decoded_meta.lease_timeout or 0,
47
+ count_key = count_key, bytes_key = bytes_key, context = decoded_meta.context,
48
+ }
49
+ local flushed = {}
50
+ while tonumber(redis.call('LLEN', buffer_key)) >= max_size do
51
+ flushed[#flushed + 1] = materialize_batch(buffer_key, batch)
52
+ end
53
+ if redis.call('LLEN', buffer_key) == 0 then
54
+ redis.call('ZREM', deadlines_key, member)
55
+ redis.call('HDEL', meta_key, member)
56
+ end
57
+ return flushed
58
+ end
59
+
60
+ local ids = {}
61
+ for _, group in ipairs(cjson.decode(ARGV[1])) do
62
+ for _, id in ipairs(append_group(group.keys, group.args)) do ids[#ids + 1] = id end
63
+ end
64
+ return ids
@@ -0,0 +1,29 @@
1
+ local batch_key, lease_key, outstanding_key, failed_key = unpack(KEYS)
2
+ local execution_jid, default_timeout, now, batch_id, lease_token = unpack(ARGV)
3
+ if redis.call('EXISTS', batch_key) == 0 then return false end
4
+ local same_job = redis.call('HGET', batch_key, 'execution_jid') == execution_jid
5
+ if redis.call('HGET', batch_key, 'exhausted') == '1' then
6
+ -- Only the exhausted processor itself comes back under the same JID,
7
+ -- through Sidekiq's Dead set retry. Replay the batch for it.
8
+ if not same_job then return false end
9
+ redis.call('HDEL', batch_key, 'exhausted', 'owner_token', 'started_at', 'last_error', 'attempts')
10
+ redis.call('DEL', lease_key)
11
+ redis.call('ZREM', failed_key, batch_id)
12
+ end
13
+ local timeout = tonumber(redis.call('HGET', batch_key, 'lease_timeout') or '0')
14
+ if timeout <= 0 then timeout = tonumber(default_timeout) end
15
+ local current = redis.call('GET', lease_key)
16
+ local retrying = redis.call('HGET', batch_key, 'state') == 'retrying'
17
+ if current and retrying and same_job then
18
+ redis.call('SET', lease_key, lease_token, 'EX', timeout)
19
+ elseif redis.call('SET', lease_key, lease_token, 'NX', 'EX', timeout) == false then
20
+ return false
21
+ end
22
+ redis.call('HSET', batch_key, 'state', 'running', 'started_at', now, 'execution_jid', execution_jid,
23
+ 'owner_token', lease_token)
24
+ redis.call('HDEL', batch_key, 'recovery_checks')
25
+ redis.call('HINCRBY', batch_key, 'attempts', 1)
26
+ redis.call('ZADD', outstanding_key, tonumber(now) + timeout, batch_id)
27
+ local values = redis.call('HMGET', batch_key, 'class', 'group', 'items', 'attempts')
28
+ values[5] = tostring(timeout)
29
+ return values
@@ -0,0 +1,7 @@
1
+ local failed_key, outstanding_key = unpack(KEYS)
2
+ local batch_prefix, lease_prefix, cutoff, limit = unpack(ARGV)
3
+ local ids = redis.call('ZRANGE', failed_key, '-inf', cutoff, 'BYSCORE', 'LIMIT', 0, tonumber(limit))
4
+ for _, id in ipairs(ids) do
5
+ delete_batch(batch_prefix .. id, lease_prefix .. id, id, outstanding_key, failed_key)
6
+ end
7
+ return ids
@@ -0,0 +1,7 @@
1
+ local batch_key, lease_key, outstanding_key, failed_key = unpack(KEYS)
2
+ local batch_id = ARGV[1]
3
+ if redis.call('EXISTS', batch_key) == 0 then return -1 end
4
+ if redis.call('HGET', batch_key, 'exhausted') ~= '1' then return 0 end
5
+ local previous_jid = redis.call('HGET', batch_key, 'execution_jid') or ''
6
+ delete_batch(batch_key, lease_key, batch_id, outstanding_key, failed_key)
7
+ return {1, previous_jid}
@@ -0,0 +1,10 @@
1
+ local batch_key, lease_key, outstanding_key, failed_key = unpack(KEYS)
2
+ local batch_id, now, token, error = unpack(ARGV)
3
+ if redis.call('EXISTS', batch_key) == 0 then return 0 end
4
+ if token ~= '' and redis.call('HGET', batch_key, 'owner_token') ~= token then return 0 end
5
+ redis.call('HSET', batch_key, 'exhausted', '1', 'state', 'failed')
6
+ if error ~= '' then redis.call('HSET', batch_key, 'last_error', error) end
7
+ redis.call('DEL', lease_key)
8
+ redis.call('ZREM', outstanding_key, batch_id)
9
+ redis.call('ZADD', failed_key, now, batch_id)
10
+ return 1
@@ -0,0 +1,7 @@
1
+ -- Heartbeat: extend a lease the caller still owns and push recovery out.
2
+ local lease_key, outstanding_key = unpack(KEYS)
3
+ local token, timeout, recover_at, batch_id = unpack(ARGV)
4
+ if redis.call('GET', lease_key) ~= token then return 0 end
5
+ redis.call('EXPIRE', lease_key, timeout)
6
+ redis.call('ZADD', outstanding_key, 'XX', recover_at, batch_id)
7
+ return 1
@@ -0,0 +1,25 @@
1
+ local buffer_key, deadlines_key, batch_seq_key, meta_key, outstanding_key = unpack(KEYS)
2
+ local member, now, batch_prefix, recover_at, fallback_id_prefix, force = unpack(ARGV)
3
+ now = tonumber(now)
4
+ local due = redis.call('ZSCORE', deadlines_key, member)
5
+ if not due or (force ~= '1' and tonumber(due) > now) then return {} end
6
+ local raw = redis.call('HGET', meta_key, member)
7
+ if not raw then
8
+ redis.call('ZREM', deadlines_key, member)
9
+ return {}
10
+ end
11
+ local meta = cjson.decode(raw)
12
+ local capacity_key = meta.capacity_key or member
13
+ local batch = {
14
+ class = meta.class, group = meta.group, max_size = tonumber(meta.max_size),
15
+ id_prefix = meta.id_prefix or fallback_id_prefix, prefix = batch_prefix, seq_key = batch_seq_key,
16
+ outstanding_key = outstanding_key, recover_at = recover_at, lease_timeout = meta.lease_timeout or 0,
17
+ count_key = capacity_key .. ':count', bytes_key = capacity_key .. ':bytes', context = meta.context,
18
+ }
19
+ local flushed = {}
20
+ while tonumber(redis.call('LLEN', buffer_key)) > 0 do
21
+ flushed[#flushed + 1] = materialize_batch(buffer_key, batch)
22
+ end
23
+ redis.call('ZREM', deadlines_key, member)
24
+ redis.call('HDEL', meta_key, member)
25
+ return flushed
@@ -0,0 +1,21 @@
1
+ -- Deletes a batch and its lease, drops it from both indexes, and returns the
2
+ -- inputs it held to the per-group counters behind Sidekiq::Buffered.stats.
3
+ local function delete_batch(batch_key, lease_key, batch_id, outstanding_key, failed_key)
4
+ local count_key = redis.call('HGET', batch_key, 'count_key')
5
+ local bytes_key = redis.call('HGET', batch_key, 'bytes_key')
6
+ local bytes = tonumber(redis.call('HGET', batch_key, 'bytes') or '0')
7
+ local raw_items = redis.call('HGET', batch_key, 'items')
8
+ local count = 0
9
+ if raw_items then count = #cjson.decode(raw_items) end
10
+ redis.call('DEL', batch_key, lease_key)
11
+ redis.call('ZREM', outstanding_key, batch_id)
12
+ redis.call('ZREM', failed_key, batch_id)
13
+ if count_key then
14
+ local left = redis.call('DECRBY', count_key, count)
15
+ if left <= 0 then redis.call('DEL', count_key) end
16
+ end
17
+ if bytes_key then
18
+ local left = redis.call('DECRBY', bytes_key, bytes)
19
+ if left <= 0 then redis.call('DEL', bytes_key) end
20
+ end
21
+ end
@@ -0,0 +1,21 @@
1
+ -- Moves the first max_size buffered items into a new batch hash, registers the
2
+ -- batch for recovery, and returns its id.
3
+ local function materialize_batch(buffer_key, batch)
4
+ local items = redis.call('LRANGE', buffer_key, 0, batch.max_size - 1)
5
+ redis.call('LTRIM', buffer_key, batch.max_size, -1)
6
+ local id = batch.id_prefix .. ':' .. tostring(redis.call('INCR', batch.seq_key))
7
+ local nbytes = 0
8
+ for i = 1, #items do
9
+ nbytes = nbytes + #items[i]
10
+ end
11
+ local key = batch.prefix .. id
12
+ redis.call('HSET', key, 'class', batch.class, 'group', batch.group, 'items', cjson.encode(items),
13
+ 'state', 'queued', 'oldest_at', cjson.decode(items[1]).buffered_at or '',
14
+ 'count_key', batch.count_key, 'bytes_key', batch.bytes_key, 'bytes', nbytes,
15
+ 'lease_timeout', batch.lease_timeout)
16
+ if batch.context then
17
+ redis.call('HSET', key, 'dispatch_context', batch.context)
18
+ end
19
+ redis.call('ZADD', batch.outstanding_key, batch.recover_at, id)
20
+ return id
21
+ end
@@ -0,0 +1,6 @@
1
+ -- Redis time as a '%.6f' string, unless the caller pinned the clock.
2
+ local function redis_time(pinned)
3
+ if pinned ~= '' then return pinned end
4
+ local t = redis.call('TIME')
5
+ return string.format('%.6f', tonumber(t[1]) + (tonumber(t[2]) / 1000000))
6
+ end
@@ -0,0 +1,5 @@
1
+ local batch_key = KEYS[1]
2
+ local queue_key, payload = unpack(ARGV)
3
+ if redis.call('EXISTS', batch_key) == 0 then return 0 end
4
+ redis.call('HSET', batch_key, 'queued_key', queue_key, 'queued_payload', payload)
5
+ return 1
@@ -0,0 +1,21 @@
1
+ local outstanding_key, lease_key, batch_key = unpack(KEYS)
2
+ local batch_id, now, recover_at, max_interval = unpack(ARGV)
3
+ now = tonumber(now)
4
+ if redis.call('EXISTS', batch_key) == 0 or redis.call('HGET', batch_key, 'exhausted') == '1' then
5
+ redis.call('ZREM', outstanding_key, batch_id)
6
+ return 0
7
+ end
8
+ local due = redis.call('ZSCORE', outstanding_key, batch_id)
9
+ if not due or tonumber(due) > now then return 0 end
10
+ if redis.call('EXISTS', lease_key) == 1 then return 0 end
11
+ -- Repeated checks back off from recovery_interval towards recovery_max_interval.
12
+ local checks = redis.call('HINCRBY', batch_key, 'recovery_checks', 1)
13
+ local base = tonumber(recover_at) - now
14
+ local delay = math.min(base * 2 ^ math.min(checks - 1, 20), math.max(base, tonumber(max_interval)))
15
+ redis.call('ZADD', outstanding_key, now + delay, batch_id)
16
+ -- A processor still waiting in its queue needs no re-dispatch.
17
+ local queue = redis.call('HGET', batch_key, 'queued_key')
18
+ local payload = redis.call('HGET', batch_key, 'queued_payload')
19
+ if queue and payload and redis.call('LPOS', queue, payload) then return 0 end
20
+ redis.call('HSET', batch_key, 'state', 'queued')
21
+ return 1
@@ -0,0 +1,8 @@
1
+ -- Sidekiq::Shutdown interrupted perform; Sidekiq requeues the same job.
2
+ local batch_key, lease_key, outstanding_key = unpack(KEYS)
3
+ local token, recover_at, batch_id = unpack(ARGV)
4
+ if redis.call('GET', lease_key) ~= token then return 0 end
5
+ redis.call('DEL', lease_key)
6
+ redis.call('HSET', batch_key, 'state', 'queued')
7
+ redis.call('ZADD', outstanding_key, 'XX', recover_at, batch_id)
8
+ return 1
@@ -0,0 +1,11 @@
1
+ local batch_key, lease_key, outstanding_key, failed_key = unpack(KEYS)
2
+ local batch_id, recover_at = unpack(ARGV)
3
+ if redis.call('EXISTS', batch_key) == 0 then return -1 end
4
+ if redis.call('HGET', batch_key, 'exhausted') ~= '1' then return 0 end
5
+ local previous_jid = redis.call('HGET', batch_key, 'execution_jid') or ''
6
+ redis.call('HDEL', batch_key, 'exhausted', 'execution_jid', 'owner_token', 'started_at', 'last_error', 'attempts')
7
+ redis.call('HSET', batch_key, 'state', 'queued')
8
+ redis.call('DEL', lease_key)
9
+ redis.call('ZREM', failed_key, batch_id)
10
+ redis.call('ZADD', outstanding_key, recover_at, batch_id)
11
+ return {1, previous_jid}
@@ -0,0 +1,13 @@
1
+ local batch_key, lease_key, outstanding_key = unpack(KEYS)
2
+ local batch_id, token, delay, now, default_timeout, error = unpack(ARGV)
3
+ if redis.call('EXISTS', batch_key) == 0 then return 0 end
4
+ if redis.call('HGET', batch_key, 'exhausted') == '1' then return 0 end
5
+ if redis.call('HGET', batch_key, 'owner_token') ~= token then return 0 end
6
+ local timeout = tonumber(redis.call('HGET', batch_key, 'lease_timeout') or '0')
7
+ if timeout <= 0 then timeout = tonumber(default_timeout) end
8
+ -- Protect recovery through the latest possible retry time plus the lease timeout.
9
+ local ttl = math.ceil(tonumber(delay)) + timeout
10
+ redis.call('HSET', batch_key, 'state', 'retrying', 'last_error', error)
11
+ redis.call('SET', lease_key, token, 'EX', ttl)
12
+ redis.call('ZADD', outstanding_key, tonumber(now) + ttl, batch_id)
13
+ return 1
@@ -0,0 +1,10 @@
1
+ -- One round trip for the poller: Redis time, due buffers, due recoveries,
2
+ -- and the next moment anything becomes due.
3
+ local deadlines_key, outstanding_key = unpack(KEYS)
4
+ local now = redis_time(ARGV[1])
5
+ local limit = tonumber(ARGV[2])
6
+ local due_buffers = redis.call('ZRANGE', deadlines_key, '-inf', now, 'BYSCORE', 'LIMIT', 0, limit)
7
+ local due_batches = redis.call('ZRANGE', outstanding_key, '-inf', now, 'BYSCORE', 'LIMIT', 0, limit)
8
+ local next_deadline = redis.call('ZRANGE', deadlines_key, 0, 0, 'WITHSCORES')
9
+ local next_recovery = redis.call('ZRANGE', outstanding_key, 0, 0, 'WITHSCORES')
10
+ return { now, due_buffers, due_batches, next_deadline[2] or '', next_recovery[2] or '' }
@@ -0,0 +1,23 @@
1
+ require_relative "script"
2
+
3
+ module Sidekiq
4
+ module Buffered
5
+ # Every Store operation is one of these scripts, so each runs atomically.
6
+ module Scripts
7
+ APPEND = Script.load("append", helpers: %w[redis_time materialize_batch])
8
+ FLUSH_DUE = Script.load("flush_due", helpers: %w[materialize_batch])
9
+ TICK = Script.load("tick", helpers: %w[redis_time])
10
+ CLAIM = Script.load("claim")
11
+ EXTEND = Script.load("extend")
12
+ RELEASE = Script.load("release")
13
+ ACK = Script.load("ack", helpers: %w[delete_batch])
14
+ DISCARD = Script.load("discard", helpers: %w[delete_batch])
15
+ CLEANUP_FAILED = Script.load("cleanup_failed", helpers: %w[delete_batch])
16
+ RETRYING = Script.load("retrying")
17
+ EXHAUST = Script.load("exhaust")
18
+ REPLAY = Script.load("replay")
19
+ RECORD_DISPATCH = Script.load("record_dispatch")
20
+ RECOVER = Script.load("recover")
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,51 @@
1
+ module Sidekiq
2
+ module Buffered
3
+ # Claims the batch before the rest of the server chain runs and acknowledges
4
+ # it only after the chain succeeds. Keep this middleware first in the chain.
5
+ class ServerMiddleware
6
+ def call(worker, job, _queue, &)
7
+ return yield unless worker.is_a?(Processor)
8
+
9
+ execution = Execution.new(job.fetch("args").first, jid: job.fetch("jid"))
10
+ return unless execution.claim
11
+
12
+ worker.execution = execution
13
+ guard(execution, job, &)
14
+ end
15
+
16
+ private
17
+
18
+ def guard(execution, job)
19
+ record = execution.record
20
+ Heartbeat.track(record.id, token: execution.token, timeout: record.lease_timeout,
21
+ redis_pool: Sidekiq.redis_pool,)
22
+ BatchContext.with(record) do
23
+ yield
24
+ acknowledge(execution) if execution.performed?
25
+ end
26
+ execution.finish
27
+ rescue Sidekiq::Shutdown
28
+ # Sidekiq requeues this job under the same JID; let it reclaim immediately.
29
+ Heartbeat.untrack(record.id)
30
+ execution.release
31
+ execution.finish
32
+ raise
33
+ rescue StandardError => e
34
+ # Sidekiq's retry handler takes the execution over through the job
35
+ # class's retry callbacks, unless retries are off and nothing will run them.
36
+ if job["retry"] == false
37
+ execution.exhaust(e)
38
+ execution.finish
39
+ end
40
+ raise
41
+ ensure
42
+ Heartbeat.untrack(record.id)
43
+ end
44
+
45
+ def acknowledge(execution)
46
+ Heartbeat.untrack(execution.batch_id)
47
+ execution.ack
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,41 @@
1
+ module Sidekiq
2
+ module Buffered
3
+ # Setter for buffered job classes. Synchronous execution accepts one input
4
+ # hash or an explicit batch and bypasses buffering.
5
+ class Setter < Sidekiq::Job::Setter
6
+ def initialize(klass, opts)
7
+ super
8
+ @buffered_class = klass
9
+ end
10
+
11
+ def perform_inline(*args)
12
+ args = [Input.normalize_args(@buffered_class.name, args)] if args.size == 1 && args.first.is_a?(Hash)
13
+ Inline.with { super(*args) }
14
+ end
15
+ alias perform_sync perform_inline
16
+
17
+ # One client call per perform_bulk so batch_size chunks and accepted JIDs
18
+ # are handled in Sidekiq::Buffered::Client regardless of Sidekiq version.
19
+ def perform_bulk(args, **options)
20
+ client = Client.new(pool: @buffered_class.get_sidekiq_options["pool"])
21
+ client.push_bulk(@opts.merge({ "class" => @buffered_class, "args" => args }, options))
22
+ end
23
+ end
24
+
25
+ # Class-level overrides for native Sidekiq::Job classes declaring +buffered+.
26
+ module NativeJobMethods
27
+ def set(options)
28
+ Setter.new(self, options)
29
+ end
30
+
31
+ def perform_inline(*)
32
+ Setter.new(self, {}).perform_inline(*)
33
+ end
34
+ alias perform_sync perform_inline
35
+
36
+ def perform_bulk(*, **)
37
+ Setter.new(self, {}).perform_bulk(*, **)
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,130 @@
1
+ module Sidekiq
2
+ module Buffered
3
+ # On-demand, paginated inspection. Counts can change while the snapshot is read.
4
+ class Statistics
5
+ PAGE_SIZE = 100
6
+ STATES = %w[queued running retrying failed].freeze
7
+
8
+ def initialize(store: Buffered.store, now: nil, job_class: nil, group: Buffered::ANY_GROUP)
9
+ @scope = Scope.new(job_class:, group:)
10
+ @capacity_keys = {}
11
+ @store = store
12
+ @redis_pool = store.redis_pool
13
+ @now = now || store.now
14
+ @pending_inputs = 0
15
+ @oldest_pending_at = nil
16
+ @oldest_input_at = nil
17
+ @batches = STATES.to_h { |state| [state.to_sym, 0] }
18
+ end
19
+
20
+ def call
21
+ read_buffers
22
+ read_batches(Store::OUTSTANDING, fallback: "queued")
23
+ read_batches(Store::FAILED, fallback: "failed")
24
+ outstanding, bytes = capacity_totals
25
+ {
26
+ outstanding_inputs: outstanding,
27
+ stored_bytes: bytes,
28
+ pending_inputs: @pending_inputs,
29
+ oldest_pending_age: age(@oldest_pending_at),
30
+ oldest_input_age: age(@oldest_input_at),
31
+ batches: @batches,
32
+ }
33
+ end
34
+
35
+ private
36
+
37
+ def read_buffers
38
+ each_page(Store::DEADLINES) do |keys|
39
+ entries = @redis_pool.with do |conn|
40
+ conn.pipelined do |pipeline|
41
+ keys.each do |key|
42
+ pipeline.call("LLEN", key)
43
+ pipeline.call("LINDEX", key, 0)
44
+ pipeline.call("HGET", Store::META, key)
45
+ end
46
+ end
47
+ end
48
+ entries.each_slice(3).with_index do |(count, first, raw_meta), index|
49
+ read_buffer(keys[index], count, first, raw_meta)
50
+ end
51
+ end
52
+ end
53
+
54
+ def read_buffer(key, count, first, raw_meta)
55
+ return unless raw_meta
56
+
57
+ meta = Sidekiq.load_json(raw_meta)
58
+ return unless matches?(meta["class"], meta["group"])
59
+
60
+ @pending_inputs += count
61
+ capacity = meta["capacity_key"] || key
62
+ @capacity_keys["#{capacity}:count"] = "#{capacity}:bytes"
63
+ time = first && Sidekiq.load_json(first)["buffered_at"]
64
+ @oldest_pending_at = earlier(@oldest_pending_at, time)
65
+ @oldest_input_at = earlier(@oldest_input_at, time)
66
+ end
67
+
68
+ def read_batches(index, fallback:)
69
+ each_page(index) do |ids|
70
+ entries = @redis_pool.with do |conn|
71
+ conn.pipelined do |pipeline|
72
+ ids.each do |id|
73
+ pipeline.call("HMGET", @store.batch_key(id), "class", "group", "state", "oldest_at",
74
+ "count_key", "bytes_key",)
75
+ end
76
+ end
77
+ end
78
+ entries.each { |entry| read_batch(entry, fallback:) }
79
+ end
80
+ end
81
+
82
+ def read_batch(entry, fallback:)
83
+ klass, group, state, oldest, count_key, bytes_key = entry
84
+ return unless klass && matches?(klass, group)
85
+
86
+ @capacity_keys[count_key] = bytes_key if count_key && bytes_key
87
+ state ||= fallback
88
+ @batches[state.to_sym] += 1 if STATES.include?(state)
89
+ @oldest_input_at = earlier(@oldest_input_at, oldest)
90
+ end
91
+
92
+ def matches?(klass, group)
93
+ @scope.matches?(klass, Input.decode_group(group))
94
+ end
95
+
96
+ def capacity_totals
97
+ @capacity_keys.to_a.each_slice(PAGE_SIZE).with_object([0, 0]) do |keys, totals|
98
+ values = @redis_pool.with { |conn| conn.call("MGET", *keys.flatten) }
99
+ values.each_slice(2) do |count, bytes|
100
+ totals[0] += count.to_i
101
+ totals[1] += bytes.to_i
102
+ end
103
+ end
104
+ end
105
+
106
+ def each_page(key)
107
+ offset = 0
108
+ loop do
109
+ members = @redis_pool.with { |conn| conn.call("ZRANGE", key, offset, offset + PAGE_SIZE - 1) }
110
+ break if members.empty?
111
+
112
+ yield members
113
+ offset += PAGE_SIZE
114
+ break if members.size < PAGE_SIZE
115
+ end
116
+ end
117
+
118
+ def earlier(current, value)
119
+ return current if value.nil? || value == ""
120
+
121
+ time = Float(value)
122
+ current ? [current, time].min : time
123
+ end
124
+
125
+ def age(timestamp)
126
+ timestamp && [@now - timestamp, 0.0].max
127
+ end
128
+ end
129
+ end
130
+ end