cogworker 0.3.0 → 0.4.1
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/attempts.rb +1 -1
- data/lib/cogworker/basic_fetch.rb +46 -3
- data/lib/cogworker/config.rb +29 -1
- data/lib/cogworker/heartbeat.rb +38 -4
- data/lib/cogworker/history/storage.rb +2 -2
- data/lib/cogworker/job_util.rb +50 -23
- data/lib/cogworker/launcher.rb +26 -2
- data/lib/cogworker/logging.rb +4 -0
- data/lib/cogworker/lua_script.rb +30 -0
- data/lib/cogworker/manager.rb +27 -4
- data/lib/cogworker/owned_key.rb +2 -2
- data/lib/cogworker/periodic/claim.lua +7 -4
- data/lib/cogworker/periodic/release_middleware.rb +2 -2
- data/lib/cogworker/periodic/running_lock.rb +29 -4
- data/lib/cogworker/periodic/ticker.rb +65 -11
- data/lib/cogworker/processor.rb +161 -49
- data/lib/cogworker/redis_keys.rb +7 -0
- data/lib/cogworker/reliable_fetch/unsupported_error.rb +11 -0
- data/lib/cogworker/reliable_fetch.rb +222 -43
- data/lib/cogworker/scheduled.rb +38 -7
- data/lib/cogworker/status/server_middleware.rb +1 -1
- data/lib/cogworker/swarm.rb +1 -0
- data/lib/cogworker/version.rb +1 -1
- data/lib/cogworker/web/optional_session.rb +32 -0
- data/lib/cogworker/web/routes/jobs.rb +58 -23
- data/lib/cogworker/web.rb +16 -2
- data/lib/cogworker.rb +11 -0
- metadata +4 -1
|
@@ -17,12 +17,33 @@ module Cogworker
|
|
|
17
17
|
|
|
18
18
|
CLAIM_SCRIPT = File.read(File.join(__dir__, 'claim.lua'))
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
# Undoes a won claim whose push then failed, so the slot isn't lost:
|
|
21
|
+
# puts `last_slot` back (only if it still is this slot — a later slot
|
|
22
|
+
# may have been claimed since) and drops the per-slot NX lock.
|
|
23
|
+
# KEYS[1] = last_slot, KEYS[2] = slot lock; ARGV[1] = slot, ARGV[2] =
|
|
24
|
+
# previous last_slot ("" if none: then slot - 1, which still lets this
|
|
25
|
+
# slot fire, unlike deleting the key — with catch_up off, a missing
|
|
26
|
+
# last_slot would be re-primed to this very slot instead).
|
|
27
|
+
ROLLBACK_SCRIPT = <<~LUA
|
|
28
|
+
if redis.call("GET", KEYS[1]) == ARGV[1] then
|
|
29
|
+
if ARGV[2] == "" then
|
|
30
|
+
redis.call("SET", KEYS[1], tostring(tonumber(ARGV[1]) - 1))
|
|
31
|
+
else
|
|
32
|
+
redis.call("SET", KEYS[1], ARGV[2])
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
redis.call("DEL", KEYS[2])
|
|
36
|
+
return 1
|
|
37
|
+
LUA
|
|
38
|
+
|
|
39
|
+
def initialize(manager, entries, catch_up: true, ready: -> { true })
|
|
21
40
|
@manager = manager
|
|
22
41
|
@entries = entries
|
|
23
42
|
@catch_up = catch_up
|
|
43
|
+
@ready = ready
|
|
24
44
|
@last_checked_slot = {}
|
|
25
45
|
@cron_cache = {}
|
|
46
|
+
@pending_rollbacks = []
|
|
26
47
|
end
|
|
27
48
|
|
|
28
49
|
def start!
|
|
@@ -57,12 +78,13 @@ module Cogworker
|
|
|
57
78
|
|
|
58
79
|
# A failed tick is logged and retried next interval rather than ending
|
|
59
80
|
# the thread — a single Redis blip used to silently stop every
|
|
60
|
-
# periodic entry in this process for good. A slot whose claim
|
|
61
|
-
# isn't recorded in `@last_checked_slot
|
|
81
|
+
# periodic entry in this process for good. A slot whose claim or push
|
|
82
|
+
# raised isn't recorded in `@last_checked_slot` (and a failed push
|
|
83
|
+
# rolls its claim back — see #enqueue), so the next tick retries it.
|
|
62
84
|
def run
|
|
63
85
|
until @manager.stopping?
|
|
64
86
|
begin
|
|
65
|
-
tick unless @manager.quiet?
|
|
87
|
+
tick unless @manager.quiet? || !@ready.call
|
|
66
88
|
rescue StandardError => e
|
|
67
89
|
Cogworker.logger.error { "Periodic tick failed: #{e.class}: #{e.message}" }
|
|
68
90
|
end
|
|
@@ -71,19 +93,21 @@ module Cogworker
|
|
|
71
93
|
end
|
|
72
94
|
|
|
73
95
|
def tick
|
|
96
|
+
retry_pending_rollbacks
|
|
74
97
|
now = Time.now
|
|
75
98
|
@entries.each do |entry|
|
|
76
99
|
slot = cron_for(entry).previous_time(now).to_i
|
|
77
100
|
next if @last_checked_slot[entry.pjid] == slot
|
|
78
101
|
|
|
79
102
|
jid = SecureRandom.hex(12)
|
|
80
|
-
|
|
103
|
+
previous = claim(entry, slot, jid) unless disabled?(entry)
|
|
104
|
+
enqueue(entry, slot, jid, previous) if previous
|
|
81
105
|
@last_checked_slot[entry.pjid] = slot
|
|
82
106
|
end
|
|
83
107
|
end
|
|
84
108
|
|
|
85
109
|
# Web UI "Disable" (`Routes::Schedules`) — skips the claim/enqueue
|
|
86
|
-
# step entirely, short-circuiting before `claim
|
|
110
|
+
# step entirely, short-circuiting before `claim` even runs, so
|
|
87
111
|
# neither `periodic:last_slot:<pjid>` nor the per-slot lock advance
|
|
88
112
|
# while disabled: the Web UI's own "LastRun" column keeps showing the
|
|
89
113
|
# last time it *actually* ran, not a due-but-skipped slot, and
|
|
@@ -93,6 +117,30 @@ module Cogworker
|
|
|
93
117
|
# persisted last_slot) still advances either way, exactly as it
|
|
94
118
|
# already did before this entry ever had a disabled state — it only
|
|
95
119
|
# stops this same tick loop from re-evaluating the same slot twice.
|
|
120
|
+
# The usual reason a push fails — Redis unreachable — makes this fail
|
|
121
|
+
# too; it's then kept and retried at the start of every tick until it
|
|
122
|
+
# goes through (before that tick looks at the slot again), rather than
|
|
123
|
+
# just logged and the slot lost. (A push that timed out *after* Redis
|
|
124
|
+
# had applied it is the one case this turns into a second run of the
|
|
125
|
+
# slot: indistinguishable from one that never arrived.)
|
|
126
|
+
def rollback_claim(entry, slot, previous_slot)
|
|
127
|
+
Cogworker.config.redis do |c|
|
|
128
|
+
keys = [RedisKeys.periodic_last_slot(entry.pjid), RedisKeys.periodic_lock(entry.pjid, slot)]
|
|
129
|
+
LuaScript.run(c, ROLLBACK_SCRIPT, keys: keys, argv: [slot, previous_slot])
|
|
130
|
+
end
|
|
131
|
+
true
|
|
132
|
+
rescue StandardError => e
|
|
133
|
+
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
|
+
false
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def retry_pending_rollbacks
|
|
139
|
+
pending = @pending_rollbacks
|
|
140
|
+
@pending_rollbacks = []
|
|
141
|
+
pending.each { |args| rollback_claim(*args) }
|
|
142
|
+
end
|
|
143
|
+
|
|
96
144
|
def disabled?(entry)
|
|
97
145
|
Cogworker.config.redis { |c| c.sismember(RedisKeys::PERIODIC_DISABLED, entry.pjid) }
|
|
98
146
|
end
|
|
@@ -101,16 +149,18 @@ module Cogworker
|
|
|
101
149
|
@cron_cache[entry.pjid] ||= Fugit::Cron.parse(entry.cron)
|
|
102
150
|
end
|
|
103
151
|
|
|
104
|
-
|
|
105
|
-
|
|
152
|
+
# nil if this process didn't win the slot; otherwise the previous
|
|
153
|
+
# `last_slot` value ("" if none), for #rollback_claim.
|
|
154
|
+
def claim(entry, slot, jid)
|
|
155
|
+
return nil if !@catch_up && priming_first_slot?(entry, slot)
|
|
106
156
|
|
|
107
157
|
result = Cogworker.config.redis do |c|
|
|
108
|
-
|
|
158
|
+
LuaScript.run(c, CLAIM_SCRIPT,
|
|
109
159
|
keys: [RedisKeys.periodic_running(entry.pjid), RedisKeys.periodic_last_slot(entry.pjid),
|
|
110
160
|
RedisKeys.periodic_lock(entry.pjid, slot)],
|
|
111
161
|
argv: [slot, entry.unique.to_s, LOCK_TTL, jid, RunningLock.queued_ttl])
|
|
112
162
|
end
|
|
113
|
-
result
|
|
163
|
+
result.is_a?(Array) ? result[1].to_s : nil
|
|
114
164
|
end
|
|
115
165
|
|
|
116
166
|
# With catch-up disabled, an entry's very first tick — ever, across
|
|
@@ -140,15 +190,19 @@ module Cogworker
|
|
|
140
190
|
# Only undone here if the push didn't actually produce a job (raised,
|
|
141
191
|
# or a client middleware swallowed it), so a job that never existed
|
|
142
192
|
# can't hold the entry for the whole `RunningLock.queued_ttl`.
|
|
143
|
-
def enqueue(entry, slot, jid)
|
|
193
|
+
def enqueue(entry, slot, jid, previous_slot)
|
|
144
194
|
job = {
|
|
145
195
|
'class' => entry.class_name, 'args' => entry.args, 'retry' => entry.retry,
|
|
146
196
|
'periodic_pjid' => entry.pjid, 'periodic_slot' => slot, 'jid' => jid
|
|
147
197
|
}
|
|
198
|
+
# Lets a requeue after a crash/shutdown re-take this run's lock
|
|
199
|
+
# (Periodic::RunningLock::RETAKE_FUNCTION, in both fetches' requeue scripts).
|
|
200
|
+
job['periodic_until_executed'] = true if entry.until_executed?
|
|
148
201
|
pushed = begin
|
|
149
202
|
Client.push(job)
|
|
150
203
|
rescue StandardError
|
|
151
204
|
RunningLock.release(entry.pjid, jid) if entry.until_executed?
|
|
205
|
+
rollback_claim(entry, slot, previous_slot)
|
|
152
206
|
raise
|
|
153
207
|
end
|
|
154
208
|
return unless entry.until_executed?
|
data/lib/cogworker/processor.rb
CHANGED
|
@@ -6,9 +6,26 @@ require 'securerandom'
|
|
|
6
6
|
module Cogworker
|
|
7
7
|
# One thread of a Manager's pool: fetch -> register in-flight -> run the
|
|
8
8
|
# server middleware chain around perform -> stats/retry -> deregister.
|
|
9
|
+
#
|
|
10
|
+
# What happens to the fetched job afterwards depends on how far it got —
|
|
11
|
+
# the one rule being that a job is never re-run because of a failure that
|
|
12
|
+
# happened *after* it ran:
|
|
13
|
+
# - failed before `perform` (registering it in `cogworker:workers`):
|
|
14
|
+
# `give_back` — straight back on its queue, it hasn't run yet;
|
|
15
|
+
# - ran (succeeded, or failed and was recorded in retry/dead): acknowledge.
|
|
16
|
+
# Everything after `perform` other than that one retry/dead write —
|
|
17
|
+
# stats, throughput, deregistering, lock extension, the attempts log —
|
|
18
|
+
# is best-effort: logged, never raised;
|
|
19
|
+
# - ran and failed, but the retry/dead write itself failed: `interrupt` —
|
|
20
|
+
# into `retry` after INTERRUPT_DELAY, counting `interrupted_count`, and
|
|
21
|
+
# into `dead` once that passes MAX_INTERRUPTS. Never straight back on
|
|
22
|
+
# the queue: a write that fails deterministically used to make that
|
|
23
|
+
# loop forever, re-running the job every second.
|
|
9
24
|
class Processor
|
|
10
25
|
ERROR_BACKOFF = 1 # seconds
|
|
11
26
|
ACK_ATTEMPTS = 3
|
|
27
|
+
MAX_INTERRUPTS = 3
|
|
28
|
+
INTERRUPT_DELAY = 30 # seconds, times the interruption count
|
|
12
29
|
|
|
13
30
|
attr_reader :thread, :tid
|
|
14
31
|
|
|
@@ -16,7 +33,6 @@ module Cogworker
|
|
|
16
33
|
@manager = manager
|
|
17
34
|
@busy = false
|
|
18
35
|
@tid = SecureRandom.hex(6)
|
|
19
|
-
@fetcher = manager.fetch_class.new(manager.queues)
|
|
20
36
|
end
|
|
21
37
|
|
|
22
38
|
def start!
|
|
@@ -54,23 +70,75 @@ module Cogworker
|
|
|
54
70
|
return
|
|
55
71
|
end
|
|
56
72
|
|
|
57
|
-
work =
|
|
73
|
+
work = begin
|
|
74
|
+
fetcher.retrieve_work
|
|
75
|
+
rescue ReliableFetch::UnsupportedError => e
|
|
76
|
+
@manager.fall_back_to_basic_fetch!(e)
|
|
77
|
+
@fetcher = nil
|
|
78
|
+
return
|
|
79
|
+
end
|
|
58
80
|
return unless work
|
|
59
81
|
|
|
60
82
|
# Fetched after shutdown began (e.g. still blocked in BRPOP when
|
|
61
83
|
# `Manager#stop!` requeued unfinished work, which it then popped right
|
|
62
84
|
# back): hand it back rather than start it, since the process is about
|
|
63
85
|
# to exit underneath it.
|
|
64
|
-
return
|
|
86
|
+
return fetcher.give_back(work) if @manager.stopping?
|
|
65
87
|
|
|
66
88
|
@manager.processor_busy!
|
|
67
89
|
@busy = true
|
|
90
|
+
disposition = :give_back
|
|
68
91
|
begin
|
|
69
|
-
execute(work)
|
|
92
|
+
disposition = execute(work)
|
|
70
93
|
ensure
|
|
71
94
|
@busy = false
|
|
72
95
|
@manager.processor_idle!
|
|
73
|
-
|
|
96
|
+
settle(work, disposition)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def settle(work, disposition)
|
|
101
|
+
case disposition
|
|
102
|
+
when :acknowledge then acknowledge(work)
|
|
103
|
+
when :give_back then give_back(work)
|
|
104
|
+
else interrupt(work, *disposition.drop(1))
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Only for a job that hasn't run yet (see the class comment). If even
|
|
109
|
+
# this fails, it stays on the in-progress list, which `Manager#stop!` /
|
|
110
|
+
# orphan recovery / `ReliableFetch.reconcile` requeue later.
|
|
111
|
+
def give_back(work)
|
|
112
|
+
fetcher.give_back(work)
|
|
113
|
+
rescue StandardError => e
|
|
114
|
+
Cogworker.logger.error do
|
|
115
|
+
"couldn't hand back jid=#{jid_of(work)} (left in progress): #{e.class}: #{e.message}"
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# The job ran and failed, but recording that in retry/dead failed. Built
|
|
120
|
+
# from the original payload (always serializable — it came from JSON)
|
|
121
|
+
# plus the failure fields, never from the in-memory job hash: whatever
|
|
122
|
+
# made that one fail to serialize must not do it again here.
|
|
123
|
+
def interrupt(work, job, error)
|
|
124
|
+
payload = JSON.parse(work.raw_job)
|
|
125
|
+
count = payload['interrupted_count'].to_i + 1
|
|
126
|
+
payload.merge!('interrupted_count' => count, 'retry_count' => job['retry_count'],
|
|
127
|
+
'error_class' => error.class.name, 'error_message' => JobUtil.error_message(error),
|
|
128
|
+
'failed_at' => Time.now.to_f)
|
|
129
|
+
set, score = if count > MAX_INTERRUPTS
|
|
130
|
+
[RedisKeys::DEAD, Time.now.to_f]
|
|
131
|
+
else
|
|
132
|
+
[RedisKeys::RETRY, Time.now.to_f + (INTERRUPT_DELAY * count)]
|
|
133
|
+
end
|
|
134
|
+
fetcher.interrupt(work, set, score, JSON.generate(payload))
|
|
135
|
+
Cogworker.logger.error do
|
|
136
|
+
"couldn't record the failure of jid=#{payload['jid']} (#{error.class}); " \
|
|
137
|
+
"moved to #{set.delete_prefix('cogworker:')} (interruption #{count}/#{MAX_INTERRUPTS})"
|
|
138
|
+
end
|
|
139
|
+
rescue StandardError => e
|
|
140
|
+
Cogworker.logger.error do
|
|
141
|
+
"couldn't record or interrupt jid=#{jid_of(work)} (left in progress): #{e.class}: #{e.message}"
|
|
74
142
|
end
|
|
75
143
|
end
|
|
76
144
|
|
|
@@ -82,7 +150,7 @@ module Cogworker
|
|
|
82
150
|
attempts = 0
|
|
83
151
|
begin
|
|
84
152
|
attempts += 1
|
|
85
|
-
|
|
153
|
+
fetcher.acknowledge(work)
|
|
86
154
|
rescue StandardError => e
|
|
87
155
|
if attempts < ACK_ATTEMPTS
|
|
88
156
|
sleep(0.1 * attempts)
|
|
@@ -101,53 +169,80 @@ module Cogworker
|
|
|
101
169
|
'?'
|
|
102
170
|
end
|
|
103
171
|
|
|
172
|
+
# Returns how `settle` should dispose of the fetched job; raises only
|
|
173
|
+
# before the job has run (then `process_one`'s default, :give_back,
|
|
174
|
+
# applies).
|
|
104
175
|
def execute(work)
|
|
105
176
|
job = parse_job(work)
|
|
106
|
-
return unless job
|
|
177
|
+
return :acknowledge unless job
|
|
107
178
|
|
|
108
179
|
register_in_workers(work.queue, job)
|
|
109
180
|
Cogworker.logger.info { "start: #{job['class']} jid=#{job['jid']}" }
|
|
110
|
-
|
|
111
181
|
begin
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
182
|
+
error = run_job(work, job)
|
|
183
|
+
error ? finish_failure(job, error) : finish_success(job)
|
|
184
|
+
ensure
|
|
185
|
+
best_effort('deregister') { deregister_from_workers }
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# The job's own outcome: nil, or what it (or its middleware) raised.
|
|
190
|
+
def run_job(work, job)
|
|
191
|
+
# `build_worker` itself can raise (most commonly `NameError`, e.g. a
|
|
192
|
+
# class the caller registered/enqueued but never actually defined —
|
|
193
|
+
# deliberately still routed through the *same* middleware chain
|
|
194
|
+
# below rather than caught here directly: `History::Middleware`/
|
|
195
|
+
# `Status::ServerMiddleware` (and any custom server middleware)
|
|
196
|
+
# only ever see a job by wrapping this `chain.invoke` call, so a
|
|
197
|
+
# resolution failure caught out here, before `invoke` ever runs,
|
|
198
|
+
# would never reach them — this job would fail/retry/die with no
|
|
199
|
+
# History entry and no status update at all, silently. Catching it
|
|
200
|
+
# here and re-raising it as the chain's own "final block" gives
|
|
201
|
+
# every middleware the same crack at observing this failure as any
|
|
202
|
+
# perform-time one, with `worker` as `nil` in that case (every
|
|
203
|
+
# built-in server middleware ignores the `worker` arg entirely
|
|
204
|
+
# already; a custom one that needs a real instance simply can't act
|
|
205
|
+
# on this failure either way — there's no worker to act on).
|
|
206
|
+
resolution_error = nil
|
|
207
|
+
worker = begin
|
|
208
|
+
build_worker(job)
|
|
209
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
210
|
+
resolution_error = e
|
|
211
|
+
nil
|
|
212
|
+
end
|
|
134
213
|
|
|
135
|
-
|
|
136
|
-
|
|
214
|
+
Cogworker.config.server_chain.invoke(worker, job, work.queue) do
|
|
215
|
+
raise resolution_error if resolution_error
|
|
137
216
|
|
|
138
|
-
|
|
139
|
-
|
|
217
|
+
worker.perform(*job['args'])
|
|
218
|
+
end
|
|
219
|
+
nil
|
|
220
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
221
|
+
e
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def finish_success(job)
|
|
225
|
+
best_effort('stats') do
|
|
140
226
|
Cogworker.config.redis { |c| c.incr(RedisKeys::STATS_PROCESSED) }
|
|
141
227
|
Throughput.record('processed')
|
|
142
|
-
|
|
143
|
-
|
|
228
|
+
end
|
|
229
|
+
Cogworker.logger.info { "done: #{job['class']} jid=#{job['jid']}" }
|
|
230
|
+
:acknowledge
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def finish_failure(job, error)
|
|
234
|
+
best_effort('stats') do
|
|
144
235
|
Cogworker.config.redis { |c| c.incr(RedisKeys::STATS_FAILED) }
|
|
145
236
|
Throughput.record('failed')
|
|
146
|
-
route_failure(job, e)
|
|
147
|
-
Cogworker.logger.warn { "fail: #{job['class']} jid=#{job['jid']}: #{e.class}: #{e&.message}" }
|
|
148
237
|
end
|
|
149
|
-
|
|
150
|
-
|
|
238
|
+
Cogworker.logger.warn { "fail: #{job['class']} jid=#{job['jid']}: #{error.class}: #{JobUtil.error_message(error)}" }
|
|
239
|
+
route_failure(job, error) ? :acknowledge : [:interrupt, job, error]
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def best_effort(what)
|
|
243
|
+
yield
|
|
244
|
+
rescue StandardError => e
|
|
245
|
+
Cogworker.logger.error { "#{what} failed (ignored): #{e.class}: #{e.message}" }
|
|
151
246
|
end
|
|
152
247
|
|
|
153
248
|
# A payload that isn't a JSON object goes straight to `dead`
|
|
@@ -174,6 +269,12 @@ module Cogworker
|
|
|
174
269
|
Cogworker.logger.error { "unparseable job on queue #{work.queue} moved to dead: #{error.message}" }
|
|
175
270
|
end
|
|
176
271
|
|
|
272
|
+
# Built on first use, not in the constructor (see Manager#fetch_class),
|
|
273
|
+
# and rebuilt after a fall-back to BasicFetch.
|
|
274
|
+
def fetcher
|
|
275
|
+
@fetcher ||= @manager.fetch_class.new(@manager.queues, stopping: -> { @manager.stopping? })
|
|
276
|
+
end
|
|
277
|
+
|
|
177
278
|
def build_worker(job)
|
|
178
279
|
klass = Object.const_get(job['class'])
|
|
179
280
|
worker = klass.new
|
|
@@ -181,24 +282,35 @@ module Cogworker
|
|
|
181
282
|
worker
|
|
182
283
|
end
|
|
183
284
|
|
|
285
|
+
# True once the failure is recorded in retry/dead — the one write that
|
|
286
|
+
# must happen; everything after it is best-effort. False if that write
|
|
287
|
+
# failed (`finish_failure` then interrupts the job instead).
|
|
184
288
|
def route_failure(job, error)
|
|
185
289
|
job['error_class'] = error.class.name
|
|
186
|
-
job['error_message'] = error
|
|
290
|
+
job['error_message'] = JobUtil.error_message(error)
|
|
187
291
|
job['failed_at'] ||= Time.now.to_f
|
|
188
292
|
|
|
189
293
|
max_retries = JobUtil.max_retries(job)
|
|
190
294
|
new_count = job['retry_count'].to_i + 1
|
|
191
295
|
job['retry_count'] = new_count
|
|
296
|
+
retrying = new_count <= max_retries
|
|
297
|
+
delay = retry_delay(new_count) if retrying
|
|
298
|
+
|
|
299
|
+
begin
|
|
300
|
+
payload = JSON.generate(job)
|
|
301
|
+
Cogworker.config.redis do |c|
|
|
302
|
+
retrying ? c.zadd(RedisKeys::RETRY, Time.now.to_f + delay, payload) : c.zadd(RedisKeys::DEAD, Time.now.to_f, payload)
|
|
303
|
+
end
|
|
304
|
+
rescue StandardError => e
|
|
305
|
+
Cogworker.logger.error { "couldn't record failure of jid=#{job['jid']}: #{e.class}: #{e.message}" }
|
|
306
|
+
return false
|
|
307
|
+
end
|
|
192
308
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
extend_locks_for_retry(job, delay)
|
|
197
|
-
Attempts.record(job['jid'], attempt: new_count, error: error, outcome: 'retrying')
|
|
198
|
-
else
|
|
199
|
-
Cogworker.config.redis { |c| c.zadd(RedisKeys::DEAD, Time.now.to_f, JSON.generate(job)) }
|
|
200
|
-
Attempts.record(job['jid'], attempt: new_count, error: error, outcome: 'dead')
|
|
309
|
+
best_effort('lock extension') { extend_locks_for_retry(job, delay) } if retrying
|
|
310
|
+
best_effort('attempts log') do
|
|
311
|
+
Attempts.record(job['jid'], attempt: new_count, error: error, outcome: retrying ? 'retrying' : 'dead')
|
|
201
312
|
end
|
|
313
|
+
true
|
|
202
314
|
end
|
|
203
315
|
|
|
204
316
|
# A job waiting out its retry backoff still holds its `until_executed`
|
data/lib/cogworker/redis_keys.rb
CHANGED
|
@@ -10,6 +10,13 @@ module Cogworker
|
|
|
10
10
|
module RedisKeys
|
|
11
11
|
QUEUE_PREFIX = 'cogworker:queue:'
|
|
12
12
|
IN_PROGRESS_PREFIX = 'cogworker:inprogress:'
|
|
13
|
+
# Every identity that has (or may have) an in-progress list — what
|
|
14
|
+
# orphan recovery walks instead of SCANning the whole keyspace.
|
|
15
|
+
IN_PROGRESS_IDENTITIES = 'cogworker:inprogress_identities'
|
|
16
|
+
# identity => Redis server time (epoch seconds) of its last heartbeat.
|
|
17
|
+
# No TTL, unlike `process(identity)`: it's what tells "dead for a
|
|
18
|
+
# while" apart from "missed a few beats" (Config#orphan_threshold).
|
|
19
|
+
LAST_BEAT = 'cogworker:last_beat'
|
|
13
20
|
QUEUES = 'cogworker:queues'
|
|
14
21
|
SCHEDULE = 'cogworker:schedule'
|
|
15
22
|
RETRY = 'cogworker:retry'
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cogworker
|
|
4
|
+
class ReliableFetch
|
|
5
|
+
# Raised by ReliableFetch#retrieve_work when the server turns out not to
|
|
6
|
+
# know LMOVE (Redis < 6.2) even though the version check at boot let
|
|
7
|
+
# `:reliable` through (e.g. Redis wasn't reachable then). The Processor
|
|
8
|
+
# reacts by switching the whole Manager over to BasicFetch.
|
|
9
|
+
class UnsupportedError < StandardError; end
|
|
10
|
+
end
|
|
11
|
+
end
|