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