cogworker 0.4.1 → 0.5.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 +4 -4
- data/lib/cogworker/basic_fetch.rb +2 -1
- data/lib/cogworker/config.rb +13 -1
- data/lib/cogworker/heartbeat.rb +23 -10
- data/lib/cogworker/job_util.rb +22 -4
- data/lib/cogworker/key_guard.rb +92 -0
- data/lib/cogworker/launcher.rb +3 -2
- data/lib/cogworker/manager.rb +58 -1
- data/lib/cogworker/periodic/ticker.rb +112 -33
- data/lib/cogworker/processor.rb +65 -14
- data/lib/cogworker/redis_keys.rb +9 -0
- data/lib/cogworker/reliable_fetch.rb +356 -54
- data/lib/cogworker/scheduled.rb +92 -16
- data/lib/cogworker/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e908112cee4c6a9f98588b6cca08c292437d1713ec351ac6287e1f5ef605c452
|
|
4
|
+
data.tar.gz: 0c271ea997d7ddf51df5aa5a16b45cb08c0b3231f71dcdb253956160c589bde3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fb759fcff852bfa57315e4428250daeac0a0ae27771dc108e1879f3847616c073ce5d2fa8e079a77d73a51d7db3f18154d5cd6c7ea45c69b1282163cf817aa75
|
|
7
|
+
data.tar.gz: caea49e5286f9bf98e93c2931c2e8a77e18b9cce59e88c7ee7854f80bfe30b7b45649a148ed08c605ae87faeeb5ce87ae7f6fe23a523625db1728db1f215b120
|
|
@@ -68,7 +68,8 @@ module Cogworker
|
|
|
68
68
|
# that's what gets pushed back — onto the end its queue is popped from,
|
|
69
69
|
# so it runs next. Returns how many. One entry that can't be read is
|
|
70
70
|
# logged and skipped, never allowed to cost the others their requeue.
|
|
71
|
-
|
|
71
|
+
# (`except:` is ReliableFetch's — there's no in-progress list here.)
|
|
72
|
+
def self.requeue_in_progress(identity, except: [], keep: []) # rubocop:disable Lint/UnusedMethodArgument
|
|
72
73
|
Cogworker.config.redis do |c|
|
|
73
74
|
c.hvals(RedisKeys.workers(identity)).count do |raw|
|
|
74
75
|
entry = JSON.parse(raw)
|
data/lib/cogworker/config.rb
CHANGED
|
@@ -9,7 +9,7 @@ module Cogworker
|
|
|
9
9
|
FETCH_MODES = %i[reliable basic].freeze
|
|
10
10
|
|
|
11
11
|
attr_reader :server_chain, :client_chain, :periodic_manager, :redis_options, :fetch, :fetch_idle_max_interval,
|
|
12
|
-
:orphan_threshold
|
|
12
|
+
:orphan_threshold, :max_orphanings
|
|
13
13
|
attr_accessor :concurrency, :queues, :periodic_catch_up, :unique_lock_ttl
|
|
14
14
|
|
|
15
15
|
def initialize
|
|
@@ -51,9 +51,21 @@ module Cogworker
|
|
|
51
51
|
# failover, a long GVL-holding call) — each of which runs its jobs
|
|
52
52
|
# twice; lower: a really crashed process's jobs come back sooner.
|
|
53
53
|
@orphan_threshold = 300
|
|
54
|
+
# How many times a job may be recovered from a process that died
|
|
55
|
+
# while running it before it goes to dead instead (ReliableFetch). The
|
|
56
|
+
# count can't single out the culprit: every job running in a process
|
|
57
|
+
# that crashed is counted, so keep this above the number of crashes an
|
|
58
|
+
# innocent job might plausibly sit through.
|
|
59
|
+
@max_orphanings = 3
|
|
54
60
|
register_default_middleware
|
|
55
61
|
end
|
|
56
62
|
|
|
63
|
+
def max_orphanings=(count)
|
|
64
|
+
raise ArgumentError, "max_orphanings must be a positive Integer, got #{count.inspect}" unless count.is_a?(Integer) && count.positive?
|
|
65
|
+
|
|
66
|
+
@max_orphanings = count
|
|
67
|
+
end
|
|
68
|
+
|
|
57
69
|
def orphan_threshold=(seconds)
|
|
58
70
|
unless seconds.is_a?(Numeric) && seconds.positive?
|
|
59
71
|
raise ArgumentError, "orphan_threshold must be a positive number of seconds, got #{seconds.inspect}"
|
data/lib/cogworker/heartbeat.rb
CHANGED
|
@@ -117,22 +117,35 @@ module Cogworker
|
|
|
117
117
|
'rss_kb' => current_rss_kb
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
+
# The presence key is what makes this process alive to everyone else
|
|
121
|
+
# (and what Heartbeat#start! waits for) — the one write a beat can't
|
|
122
|
+
# do without. Everything else is written on its own, best-effort: one
|
|
123
|
+
# shared key of the wrong type used to fail every beat of every
|
|
124
|
+
# process, so no new process could start anywhere in the fleet.
|
|
120
125
|
Cogworker.config.redis do |c|
|
|
121
|
-
c.sadd?(RedisKeys::PROCESSES, identity)
|
|
122
126
|
c.hset(RedisKeys.process(identity),
|
|
123
127
|
'info', JSON.generate(info),
|
|
124
128
|
'busy', @manager.busy_count.to_s,
|
|
125
129
|
'quiet', @manager.quiet?.to_s)
|
|
126
130
|
c.expire(RedisKeys.process(identity), TTL)
|
|
127
|
-
c.
|
|
131
|
+
secondary('process registry') { c.sadd?(RedisKeys::PROCESSES, identity) }
|
|
132
|
+
secondary('workers expiry') { c.expire(RedisKeys.workers(identity), TTL) }
|
|
128
133
|
if reliable
|
|
129
|
-
c.sadd?(RedisKeys::IN_PROGRESS_IDENTITIES, identity)
|
|
130
|
-
c.hset(RedisKeys::LAST_BEAT, identity, redis_now(c))
|
|
134
|
+
secondary('in-progress registry') { c.sadd?(RedisKeys::IN_PROGRESS_IDENTITIES, identity) }
|
|
135
|
+
secondary('last-beat stamp') { c.hset(RedisKeys::LAST_BEAT, identity, redis_now(c)) }
|
|
131
136
|
end
|
|
132
137
|
touch_running_periodic_locks(c, identity)
|
|
133
138
|
end
|
|
134
139
|
end
|
|
135
140
|
|
|
141
|
+
def secondary(what)
|
|
142
|
+
yield
|
|
143
|
+
rescue Redis::BaseConnectionError
|
|
144
|
+
raise # Redis itself is gone: the beat as a whole failed
|
|
145
|
+
rescue StandardError => e
|
|
146
|
+
Cogworker.logger.error { "Heartbeat: #{what} not written: #{e.class}: #{e.message}" }
|
|
147
|
+
end
|
|
148
|
+
|
|
136
149
|
# Redis's clock, not this host's (so host clock skew doesn't matter to
|
|
137
150
|
# orphan detection) — falling back to this host's where `TIME` isn't
|
|
138
151
|
# allowed (some proxies/managed services): better a skewed stamp than a
|
|
@@ -145,12 +158,12 @@ module Cogworker
|
|
|
145
158
|
|
|
146
159
|
# Keeps every in-flight `until_executed` periodic run's
|
|
147
160
|
# `periodic:running:<pjid>` lock alive for as long as this process is —
|
|
148
|
-
# see Periodic::RunningLock.
|
|
149
|
-
#
|
|
150
|
-
#
|
|
151
|
-
def touch_running_periodic_locks(conn,
|
|
152
|
-
|
|
153
|
-
job = JSON.parse(raw)
|
|
161
|
+
# see Periodic::RunningLock. From the Manager's own in-memory record of
|
|
162
|
+
# what's running here, not `cogworker:workers` in Redis: a failover that
|
|
163
|
+
# lost that Hash would otherwise let a long run's lock lapse mid-run.
|
|
164
|
+
def touch_running_periodic_locks(conn, _identity)
|
|
165
|
+
@manager.running_jobs.each do |raw|
|
|
166
|
+
job = JSON.parse(raw)
|
|
154
167
|
next unless job.is_a?(Hash) && job['periodic_pjid']
|
|
155
168
|
|
|
156
169
|
Periodic::RunningLock.touch(job['periodic_pjid'], job['jid'], Periodic::RunningLock.active_ttl, conn)
|
data/lib/cogworker/job_util.rb
CHANGED
|
@@ -37,11 +37,18 @@ module Cogworker
|
|
|
37
37
|
# define `#to_i` (`nil.to_i` happens to be `0` — coincidentally correct
|
|
38
38
|
# for absent — but `false.to_i` raises `NoMethodError` outright, which is
|
|
39
39
|
# exactly the bug this method exists to not have).
|
|
40
|
+
#
|
|
41
|
+
# Anything else (a Hash, a non-numeric String…) means no retries — the
|
|
42
|
+
# cautious reading for a job with side effects — rather than raising
|
|
43
|
+
# from the middle of recording its failure.
|
|
40
44
|
def max_retries(job)
|
|
41
|
-
case job['retry']
|
|
45
|
+
case (value = job['retry'])
|
|
42
46
|
when false then 0
|
|
43
47
|
when true, nil then DEFAULT_MAX_RETRY_ATTEMPTS
|
|
44
|
-
|
|
48
|
+
when Integer then value
|
|
49
|
+
when Float then value.to_i
|
|
50
|
+
when String then Integer(value, exception: false) || 0
|
|
51
|
+
else 0
|
|
45
52
|
end
|
|
46
53
|
end
|
|
47
54
|
|
|
@@ -75,12 +82,17 @@ module Cogworker
|
|
|
75
82
|
# commands, a crash or dropped connection between the ZREM and the
|
|
76
83
|
# LPUSH lost the job. The new payload is built in Ruby, not re-encoded
|
|
77
84
|
# by Lua's cjson (which would round large integers in args).
|
|
85
|
+
# Present? Push, then remove — never remove first: a script stopped by
|
|
86
|
+
# an error keeps the writes made before it, so a ZREM followed by a
|
|
87
|
+
# failing LPUSH (the queue key holding the wrong type) lost the entry.
|
|
88
|
+
# The `cogworker:queues` listing is best-effort.
|
|
78
89
|
CLAIM_AND_REQUEUE_SCRIPT = <<~LUA
|
|
79
|
-
if redis.call("
|
|
90
|
+
if not redis.call("ZSCORE", KEYS[1], ARGV[1]) then
|
|
80
91
|
return 0
|
|
81
92
|
end
|
|
82
|
-
redis.call("SADD", KEYS[2], ARGV[2])
|
|
83
93
|
redis.call("LPUSH", KEYS[3], ARGV[3])
|
|
94
|
+
redis.call("ZREM", KEYS[1], ARGV[1])
|
|
95
|
+
redis.pcall("SADD", KEYS[2], ARGV[2])
|
|
84
96
|
return 1
|
|
85
97
|
LUA
|
|
86
98
|
|
|
@@ -120,8 +132,14 @@ module Cogworker
|
|
|
120
132
|
limit ? str[0, limit] : str
|
|
121
133
|
end
|
|
122
134
|
|
|
135
|
+
# Never raises: an exception whose own `#message` raises (a broken
|
|
136
|
+
# custom exception class) used to escape from the middle of recording
|
|
137
|
+
# its failure — and, the job then counting as not-yet-run, it was handed
|
|
138
|
+
# back and re-run about once a second, forever.
|
|
123
139
|
def error_message(error)
|
|
124
140
|
safe_string(error.message, ERROR_MESSAGE_LIMIT)
|
|
141
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
142
|
+
"#<#{error.class}>"
|
|
125
143
|
end
|
|
126
144
|
|
|
127
145
|
UNPARSEABLE_CLASS = '(unparseable)'
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
module Cogworker
|
|
6
|
+
# Finds this gem's Redis keys holding the wrong type of value — which
|
|
7
|
+
# only a manual write or data corruption produces, but which then breaks
|
|
8
|
+
# every command on that key, deterministically: a queue that can't be
|
|
9
|
+
# pushed to, a `dead` set nothing can be filed in, a registry that fails
|
|
10
|
+
# every beat — and moves each such key out of the way, to
|
|
11
|
+
# `cogworker:quarantine:<key>:<time>:<random>` (kept QUARANTINE_TTL for a human to
|
|
12
|
+
# look at), with an error in the log. Everything then carries on with a
|
|
13
|
+
# fresh, empty key of the right type. Run by every process's Scheduled
|
|
14
|
+
# poller at boot and every CHECK_INTERVAL.
|
|
15
|
+
module KeyGuard
|
|
16
|
+
CHECK_INTERVAL = 60
|
|
17
|
+
QUARANTINE_TTL = 30 * 24 * 60 * 60
|
|
18
|
+
QUARANTINE_PREFIX = 'cogworker:quarantine:'
|
|
19
|
+
|
|
20
|
+
FIXED = {
|
|
21
|
+
RedisKeys::SCHEDULE => 'zset', RedisKeys::RETRY => 'zset', RedisKeys::DEAD => 'zset',
|
|
22
|
+
RedisKeys::QUEUES => 'set', RedisKeys::PROCESSES => 'set', RedisKeys::PAUSED_QUEUES => 'set',
|
|
23
|
+
RedisKeys::IN_PROGRESS_IDENTITIES => 'set', RedisKeys::LAST_BEAT => 'hash',
|
|
24
|
+
RedisKeys::REPEAT_ORPHANS => 'list', RedisKeys::UNSETTLED => 'list', RedisKeys::PERIODIC_SCHEDULE => 'hash',
|
|
25
|
+
RedisKeys::PERIODIC_DISABLED => 'set', RedisKeys::STATS_PROCESSED => 'string',
|
|
26
|
+
RedisKeys::STATS_FAILED => 'string'
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
# KEYS[1] = the key, KEYS[2] = its quarantine name; ARGV[1] = expected
|
|
30
|
+
# type, ARGV[2] = quarantine TTL. Re-checked here, atomically with the
|
|
31
|
+
# move, so a key fixed (or recreated) in between is left alone.
|
|
32
|
+
QUARANTINE_SCRIPT = <<~LUA
|
|
33
|
+
local actual = redis.call("TYPE", KEYS[1])["ok"]
|
|
34
|
+
if actual == "none" or actual == ARGV[1] then
|
|
35
|
+
return 0
|
|
36
|
+
end
|
|
37
|
+
redis.call("RENAME", KEYS[1], KEYS[2])
|
|
38
|
+
redis.call("EXPIRE", KEYS[2], ARGV[2])
|
|
39
|
+
return 1
|
|
40
|
+
LUA
|
|
41
|
+
|
|
42
|
+
module_function
|
|
43
|
+
|
|
44
|
+
# Returns the keys quarantined.
|
|
45
|
+
def check(queues: Cogworker.config.queues)
|
|
46
|
+
expected = expected_types(queues)
|
|
47
|
+
actual = Cogworker.config.redis do |c|
|
|
48
|
+
c.pipelined { |p| expected.each_key { |key| p.type(key) } }
|
|
49
|
+
end
|
|
50
|
+
expected.keys.zip(actual).filter_map do |key, type|
|
|
51
|
+
next if type == 'none' || type == expected[key]
|
|
52
|
+
|
|
53
|
+
key if quarantine(key, expected[key], type)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The fixed keys, plus the per-queue and per-process ones currently
|
|
58
|
+
# listed in them (only where the listing itself is readable).
|
|
59
|
+
def expected_types(queues)
|
|
60
|
+
types = FIXED.dup
|
|
61
|
+
queue_names = Array(queues) | readable_members(RedisKeys::QUEUES)
|
|
62
|
+
queue_names.each { |q| types[RedisKeys.queue(q)] = 'list' }
|
|
63
|
+
readable_members(RedisKeys::IN_PROGRESS_IDENTITIES).each { |id| types[RedisKeys.in_progress(id)] = 'list' }
|
|
64
|
+
readable_members(RedisKeys::PROCESSES).each do |id|
|
|
65
|
+
types[RedisKeys.process(id)] = 'hash'
|
|
66
|
+
types[RedisKeys.workers(id)] = 'hash'
|
|
67
|
+
end
|
|
68
|
+
types
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def readable_members(set)
|
|
72
|
+
Cogworker.config.redis { |c| c.smembers(set) }
|
|
73
|
+
rescue Redis::CommandError
|
|
74
|
+
[] # itself of the wrong type: quarantined this round, listed again next
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def quarantine(key, expected, actual)
|
|
78
|
+
# Unique per move: a second quarantine of the same key within the same
|
|
79
|
+
# second must not overwrite (RENAME) the first one's copy.
|
|
80
|
+
target = "#{QUARANTINE_PREFIX}#{key}:#{Time.now.to_i}:#{SecureRandom.hex(4)}"
|
|
81
|
+
moved = Cogworker.config.redis do |c|
|
|
82
|
+
LuaScript.run(c, QUARANTINE_SCRIPT, keys: [key, target], argv: [expected, QUARANTINE_TTL])
|
|
83
|
+
end
|
|
84
|
+
return false unless moved == 1
|
|
85
|
+
|
|
86
|
+
Cogworker.logger.error do
|
|
87
|
+
"#{key} held a #{actual}, not a #{expected}: moved to #{target} (kept #{QUARANTINE_TTL / 86_400} days)"
|
|
88
|
+
end
|
|
89
|
+
true
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
data/lib/cogworker/launcher.rb
CHANGED
|
@@ -13,14 +13,15 @@ module Cogworker
|
|
|
13
13
|
@manager = Manager.new(config)
|
|
14
14
|
@scheduled = Scheduled.new(@manager)
|
|
15
15
|
@heartbeat = Heartbeat.new(@manager)
|
|
16
|
-
# The first tick waits for the first orphan check
|
|
16
|
+
# The first tick waits for the first orphan check that really
|
|
17
|
+
# completed (Scheduled#ready_for_ticker?): after the whole
|
|
17
18
|
# fleet was down longer than a running `until_executed` job's lock
|
|
18
19
|
# lives, the ticker would otherwise claim that entry's next slot before
|
|
19
20
|
# recovery had requeued the interrupted run (and re-taken its lock) —
|
|
20
21
|
# two runs at once.
|
|
21
22
|
@ticker = Periodic::Ticker.new(
|
|
22
23
|
@manager, config.periodic_manager.entries, catch_up: config.periodic_catch_up,
|
|
23
|
-
ready: -> { @scheduled.
|
|
24
|
+
ready: -> { @scheduled.ready_for_ticker? }
|
|
24
25
|
)
|
|
25
26
|
@signal_queue = ::Queue.new
|
|
26
27
|
end
|
data/lib/cogworker/manager.rb
CHANGED
|
@@ -7,6 +7,16 @@ module Cogworker
|
|
|
7
7
|
def initialize(config = Cogworker.config)
|
|
8
8
|
@config = config
|
|
9
9
|
@fetch_mutex = Mutex.new
|
|
10
|
+
# This process's own record of its jobs (raw payloads, as fetched):
|
|
11
|
+
# running right now (a count — two copies of one payload can run at
|
|
12
|
+
# once), and finished but not yet settled in Redis — `:ack` (the ack
|
|
13
|
+
# failed) or `[set, score, payload]` (the job ran and failed, and
|
|
14
|
+
# filing it in retry/dead failed too). In memory on purpose:
|
|
15
|
+
# ReliableFetch.reconcile trusts it over anything in Redis, which may
|
|
16
|
+
# be what's failing (or what lost its last writes in a failover).
|
|
17
|
+
@jobs_mutex = Mutex.new
|
|
18
|
+
@running_jobs = Hash.new(0)
|
|
19
|
+
@pending_settlements = {}
|
|
10
20
|
@processors = Array.new(config.concurrency) { Processor.new(self) }
|
|
11
21
|
@quiet = false
|
|
12
22
|
@stopping = false
|
|
@@ -63,6 +73,34 @@ module Cogworker
|
|
|
63
73
|
@stopping
|
|
64
74
|
end
|
|
65
75
|
|
|
76
|
+
def job_started(raw)
|
|
77
|
+
@jobs_mutex.synchronize { @running_jobs[raw] += 1 }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def job_finished(raw)
|
|
81
|
+
@jobs_mutex.synchronize do
|
|
82
|
+
@running_jobs[raw] -= 1
|
|
83
|
+
@running_jobs.delete(raw) unless @running_jobs[raw].positive?
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def running_jobs
|
|
88
|
+
@jobs_mutex.synchronize { @running_jobs.keys }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# `how`: :ack, or [set, score, payload] — see ReliableFetch.settle.
|
|
92
|
+
def settle_later(raw, how)
|
|
93
|
+
@jobs_mutex.synchronize { @pending_settlements[raw] = how }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def settled(raw)
|
|
97
|
+
@jobs_mutex.synchronize { @pending_settlements.delete(raw) }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def pending_settlements
|
|
101
|
+
@jobs_mutex.synchronize { @pending_settlements.dup }
|
|
102
|
+
end
|
|
103
|
+
|
|
66
104
|
def busy_count
|
|
67
105
|
@busy_mutex.synchronize { @busy_count }
|
|
68
106
|
end
|
|
@@ -109,8 +147,27 @@ module Cogworker
|
|
|
109
147
|
end
|
|
110
148
|
end
|
|
111
149
|
|
|
150
|
+
# Jobs that finished but aren't settled in Redis yet are still on the
|
|
151
|
+
# in-progress list. One more try at settling them first; then `except:`
|
|
152
|
+
# drops the ones only waiting for their ack (which is what dropping
|
|
153
|
+
# them is), in the same atomic step as requeueing the rest — a separate
|
|
154
|
+
# ack pass that failed used to leave them to be requeued and run again.
|
|
155
|
+
# Ones that couldn't be filed in retry/dead even now are handed over to
|
|
156
|
+
# UNSETTLED for any live process to file later (ReliableFetch.park_unsettled)
|
|
157
|
+
# — never requeued: they already ran. Any that can't even be handed
|
|
158
|
+
# over are left on the list (`keep:`): orphan recovery requeues them
|
|
159
|
+
# once this process is gone, at most `max_orphanings` times.
|
|
112
160
|
def requeue_unfinished
|
|
113
|
-
|
|
161
|
+
keep = []
|
|
162
|
+
if fetch_class == ReliableFetch
|
|
163
|
+
ReliableFetch.settle_pending(Cogworker.identity, pending_settlements) { |raw| settled(raw) }
|
|
164
|
+
unfiled = pending_settlements.reject { |_raw, how| how == :ack }
|
|
165
|
+
keep = ReliableFetch.park_unsettled(Cogworker.identity, unfiled)
|
|
166
|
+
(unfiled.keys - keep).each { |raw| settled(raw) }
|
|
167
|
+
end
|
|
168
|
+
to_ack = pending_settlements.select { |_raw, how| how == :ack }.keys
|
|
169
|
+
moved = fetch_class.requeue_in_progress(Cogworker.identity, except: to_ack, keep: keep)
|
|
170
|
+
to_ack.each { |raw| settled(raw) }
|
|
114
171
|
Cogworker.logger.warn { "requeued #{moved} job(s) unfinished at shutdown" } if moved.positive?
|
|
115
172
|
rescue StandardError => e
|
|
116
173
|
Cogworker.logger.error { "Requeueing unfinished jobs failed: #{e.class}: #{e.message}" }
|
|
@@ -20,10 +20,14 @@ module Cogworker
|
|
|
20
20
|
# Undoes a won claim whose push then failed, so the slot isn't lost:
|
|
21
21
|
# puts `last_slot` back (only if it still is this slot — a later slot
|
|
22
22
|
# may have been claimed since) and drops the per-slot NX lock.
|
|
23
|
-
# KEYS[1] = last_slot, KEYS[2] = slot lock
|
|
24
|
-
# previous last_slot ("" if none: then
|
|
25
|
-
# slot fire, unlike deleting the key —
|
|
26
|
-
# last_slot would be re-primed to this
|
|
23
|
+
# KEYS[1] = last_slot, KEYS[2] = slot lock, KEYS[3] = running lock;
|
|
24
|
+
# ARGV[1] = slot, ARGV[2] = previous last_slot ("" if none: then
|
|
25
|
+
# slot - 1, which still lets this slot fire, unlike deleting the key —
|
|
26
|
+
# with catch_up off, a missing last_slot would be re-primed to this
|
|
27
|
+
# very slot instead), ARGV[3] = the jid the claim took the running lock
|
|
28
|
+
# under. Releasing that lock happens here too, in the same step: as a
|
|
29
|
+
# separate call made first, its failure (Redis down — the usual reason
|
|
30
|
+
# the push failed) skipped the rollback altogether.
|
|
27
31
|
ROLLBACK_SCRIPT = <<~LUA
|
|
28
32
|
if redis.call("GET", KEYS[1]) == ARGV[1] then
|
|
29
33
|
if ARGV[2] == "" then
|
|
@@ -33,6 +37,12 @@ module Cogworker
|
|
|
33
37
|
end
|
|
34
38
|
end
|
|
35
39
|
redis.call("DEL", KEYS[2])
|
|
40
|
+
-- Best-effort (an entry without until_executed has no lock; one of
|
|
41
|
+
-- the wrong type mustn't make an applied rollback count as failed
|
|
42
|
+
-- and be retried forever).
|
|
43
|
+
if redis.pcall("GET", KEYS[3]) == ARGV[3] then
|
|
44
|
+
redis.pcall("DEL", KEYS[3])
|
|
45
|
+
end
|
|
36
46
|
return 1
|
|
37
47
|
LUA
|
|
38
48
|
|
|
@@ -44,10 +54,19 @@ module Cogworker
|
|
|
44
54
|
@last_checked_slot = {}
|
|
45
55
|
@cron_cache = {}
|
|
46
56
|
@pending_rollbacks = []
|
|
57
|
+
@failures = Hash.new(0) # pjid => consecutive failed ticks
|
|
58
|
+
@retry_at = {} # pjid => monotonic time it may be tried again
|
|
47
59
|
end
|
|
48
60
|
|
|
61
|
+
# Publishing is only for restart survival and the Web UI: its failure
|
|
62
|
+
# (Redis down at boot, the key holding the wrong type) is logged, and
|
|
63
|
+
# must not take the process down with it.
|
|
49
64
|
def start!
|
|
50
|
-
|
|
65
|
+
begin
|
|
66
|
+
publish_schedule!
|
|
67
|
+
rescue StandardError => e
|
|
68
|
+
Cogworker.logger.error { "Periodic schedule not published: #{e.class}: #{e.message}" }
|
|
69
|
+
end
|
|
51
70
|
@thread = Thread.new { run }
|
|
52
71
|
end
|
|
53
72
|
|
|
@@ -96,53 +115,114 @@ module Cogworker
|
|
|
96
115
|
retry_pending_rollbacks
|
|
97
116
|
now = Time.now
|
|
98
117
|
@entries.each do |entry|
|
|
99
|
-
|
|
100
|
-
next if @last_checked_slot[entry.pjid] == slot
|
|
118
|
+
next if backing_off?(entry.pjid)
|
|
101
119
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
120
|
+
tick_entry(entry, now)
|
|
121
|
+
@failures.delete(entry.pjid)
|
|
122
|
+
rescue StandardError => e
|
|
123
|
+
# Per entry: one entry failing (its keys of the wrong type, its
|
|
124
|
+
# push failing) used to skip every entry after it, on every tick.
|
|
125
|
+
# Its slot isn't recorded as checked, so it's retried — after an
|
|
126
|
+
# exponential pause (`back_off`), not on every 5s tick, which made a
|
|
127
|
+
# deterministically failing entry hammer Redis and the log. Not for
|
|
128
|
+
# a lost connection, though: that's Redis being away, not the
|
|
129
|
+
# entry failing — backing off every entry on it left cron idle for
|
|
130
|
+
# minutes after Redis came back.
|
|
131
|
+
delay = e.is_a?(Redis::BaseConnectionError) ? TICK_INTERVAL : back_off(entry.pjid)
|
|
132
|
+
Cogworker.logger.error { "Periodic tick failed for #{entry.pjid} (next try in #{delay}s): #{e.class}: #{e.message}" }
|
|
106
133
|
end
|
|
107
134
|
end
|
|
108
135
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
#
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
136
|
+
BACKOFF_MAX = 300
|
|
137
|
+
|
|
138
|
+
def backing_off?(key)
|
|
139
|
+
(at = @retry_at[key]) && ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) < at
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# 5s, 10s, 20s … up to BACKOFF_MAX; reset by the next success.
|
|
143
|
+
def back_off(key)
|
|
144
|
+
@failures[key] += 1
|
|
145
|
+
delay = [TICK_INTERVAL * (2**(@failures[key] - 1)), BACKOFF_MAX].min
|
|
146
|
+
@retry_at[key] = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + delay
|
|
147
|
+
delay
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def tick_entry(entry, now)
|
|
151
|
+
slot = cron_for(entry).previous_time(now).to_i
|
|
152
|
+
return if @last_checked_slot[entry.pjid] == slot
|
|
153
|
+
|
|
154
|
+
jid = SecureRandom.hex(12)
|
|
155
|
+
previous = claim(entry, slot, jid) unless disabled?(entry)
|
|
156
|
+
enqueue(entry, slot, jid, previous) if previous
|
|
157
|
+
@last_checked_slot[entry.pjid] = slot
|
|
158
|
+
end
|
|
159
|
+
|
|
120
160
|
# The usual reason a push fails — Redis unreachable — makes this fail
|
|
121
161
|
# too; it's then kept and retried at the start of every tick until it
|
|
122
162
|
# goes through (before that tick looks at the slot again), rather than
|
|
123
|
-
# just logged and the slot lost.
|
|
124
|
-
# had applied it is the one case this
|
|
125
|
-
# slot: indistinguishable from one that
|
|
126
|
-
|
|
163
|
+
# just logged and the slot lost. Returns true, or the error. (A push
|
|
164
|
+
# that timed out *after* Redis had applied it is the one case this
|
|
165
|
+
# turns into a second run of the slot: indistinguishable from one that
|
|
166
|
+
# never arrived.) Pending
|
|
167
|
+
# rollbacks live in this process's memory only: one still pending when
|
|
168
|
+
# the process restarts is lost — that slot is skipped, and an
|
|
169
|
+
# `until_executed` entry stays locked until its lock's TTL.
|
|
170
|
+
def rollback_claim(entry, slot, previous_slot, jid)
|
|
127
171
|
Cogworker.config.redis do |c|
|
|
128
|
-
keys = [RedisKeys.periodic_last_slot(entry.pjid), RedisKeys.periodic_lock(entry.pjid, slot)
|
|
129
|
-
|
|
172
|
+
keys = [RedisKeys.periodic_last_slot(entry.pjid), RedisKeys.periodic_lock(entry.pjid, slot),
|
|
173
|
+
RedisKeys.periodic_running(entry.pjid)]
|
|
174
|
+
LuaScript.run(c, ROLLBACK_SCRIPT, keys: keys, argv: [slot, previous_slot, jid])
|
|
130
175
|
end
|
|
131
176
|
true
|
|
132
177
|
rescue StandardError => e
|
|
133
178
|
Cogworker.logger.error { "periodic #{entry.pjid}: couldn't roll back slot #{slot} (will retry): #{e.class}: #{e.message}" }
|
|
134
|
-
@pending_rollbacks << [entry, slot, previous_slot]
|
|
135
|
-
|
|
179
|
+
@pending_rollbacks << [entry, slot, previous_slot, jid]
|
|
180
|
+
e # (truthy, but not `true`: the caller tells a lost connection from a failing rollback)
|
|
136
181
|
end
|
|
137
182
|
|
|
183
|
+
# With the same backoff as a failing entry (keyed by the rollback): a
|
|
184
|
+
# rollback that keeps failing is retried less and less often, but
|
|
185
|
+
# always before that entry's slot is looked at again.
|
|
138
186
|
def retry_pending_rollbacks
|
|
139
187
|
pending = @pending_rollbacks
|
|
140
188
|
@pending_rollbacks = []
|
|
141
|
-
pending.each
|
|
189
|
+
pending.each do |args|
|
|
190
|
+
key = "rollback:#{args[0].pjid}:#{args[1]}"
|
|
191
|
+
next @pending_rollbacks << args if backing_off?(key)
|
|
192
|
+
|
|
193
|
+
result = rollback_claim(*args)
|
|
194
|
+
if result == true
|
|
195
|
+
@failures.delete(key)
|
|
196
|
+
@retry_at.delete(key)
|
|
197
|
+
elsif !result.is_a?(Redis::BaseConnectionError)
|
|
198
|
+
back_off(key) # only a rollback failing on its own backs off; see #tick
|
|
199
|
+
end
|
|
200
|
+
end
|
|
142
201
|
end
|
|
143
202
|
|
|
203
|
+
# Web UI "Disable" (`Routes::Schedules`) — skips the claim/enqueue
|
|
204
|
+
# step entirely, short-circuiting before `claim` even runs, so
|
|
205
|
+
# neither `periodic:last_slot:<pjid>` nor the per-slot lock advance
|
|
206
|
+
# while disabled: the Web UI's own "LastRun" column keeps showing the
|
|
207
|
+
# last time it *actually* ran, not a due-but-skipped slot, and
|
|
208
|
+
# re-enabling doesn't trigger a catch-up burst for every slot that
|
|
209
|
+
# was silently skipped in between. `@last_checked_slot` (this one
|
|
210
|
+
# Ticker instance's own in-memory dedup, unrelated to the Redis-
|
|
211
|
+
# persisted last_slot) still advances either way, exactly as it
|
|
212
|
+
# already did before this entry ever had a disabled state — it only
|
|
213
|
+
# stops this same tick loop from re-evaluating the same slot twice.
|
|
214
|
+
#
|
|
215
|
+
# A disabled-set that can't be read (the key holding the wrong type)
|
|
216
|
+
# counts as "not disabled", logged: it used to fail every entry's tick,
|
|
217
|
+
# stopping all cron. Losing a Disable is the lesser evil. A dropped
|
|
218
|
+
# connection still raises (the tick is then retried as a whole).
|
|
144
219
|
def disabled?(entry)
|
|
145
220
|
Cogworker.config.redis { |c| c.sismember(RedisKeys::PERIODIC_DISABLED, entry.pjid) }
|
|
221
|
+
rescue Redis::BaseConnectionError
|
|
222
|
+
raise
|
|
223
|
+
rescue StandardError => e
|
|
224
|
+
Cogworker.logger.error { "Periodic: can't read #{RedisKeys::PERIODIC_DISABLED} (#{e.class}: #{e.message}); treating #{entry.pjid} as enabled" }
|
|
225
|
+
false
|
|
146
226
|
end
|
|
147
227
|
|
|
148
228
|
def cron_for(entry)
|
|
@@ -201,8 +281,7 @@ module Cogworker
|
|
|
201
281
|
pushed = begin
|
|
202
282
|
Client.push(job)
|
|
203
283
|
rescue StandardError
|
|
204
|
-
|
|
205
|
-
rollback_claim(entry, slot, previous_slot)
|
|
284
|
+
rollback_claim(entry, slot, previous_slot, jid)
|
|
206
285
|
raise
|
|
207
286
|
end
|
|
208
287
|
return unless entry.until_executed?
|