cogworker 0.4.0 → 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/attempts.rb +1 -1
- data/lib/cogworker/basic_fetch.rb +26 -5
- data/lib/cogworker/config.rb +13 -1
- data/lib/cogworker/heartbeat.rb +33 -10
- data/lib/cogworker/history/storage.rb +2 -2
- data/lib/cogworker/job_util.rb +49 -9
- data/lib/cogworker/key_guard.rb +92 -0
- data/lib/cogworker/launcher.rb +8 -1
- data/lib/cogworker/lua_script.rb +30 -0
- data/lib/cogworker/manager.rb +63 -6
- data/lib/cogworker/owned_key.rb +2 -2
- data/lib/cogworker/periodic/ticker.rb +122 -25
- data/lib/cogworker/processor.rb +194 -59
- data/lib/cogworker/redis_keys.rb +9 -0
- data/lib/cogworker/reliable_fetch.rb +480 -87
- data/lib/cogworker/scheduled.rb +100 -14
- data/lib/cogworker/status/server_middleware.rb +1 -1
- data/lib/cogworker/version.rb +1 -1
- data/lib/cogworker/web/optional_session.rb +7 -0
- data/lib/cogworker/web.rb +5 -4
- metadata +3 -1
|
@@ -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,19 +37,36 @@ 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
|
|
|
39
|
-
def initialize(manager, entries, catch_up: true)
|
|
49
|
+
def initialize(manager, entries, catch_up: true, ready: -> { true })
|
|
40
50
|
@manager = manager
|
|
41
51
|
@entries = entries
|
|
42
52
|
@catch_up = catch_up
|
|
53
|
+
@ready = ready
|
|
43
54
|
@last_checked_slot = {}
|
|
44
55
|
@cron_cache = {}
|
|
56
|
+
@pending_rollbacks = []
|
|
57
|
+
@failures = Hash.new(0) # pjid => consecutive failed ticks
|
|
58
|
+
@retry_at = {} # pjid => monotonic time it may be tried again
|
|
45
59
|
end
|
|
46
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.
|
|
47
64
|
def start!
|
|
48
|
-
|
|
65
|
+
begin
|
|
66
|
+
publish_schedule!
|
|
67
|
+
rescue StandardError => e
|
|
68
|
+
Cogworker.logger.error { "Periodic schedule not published: #{e.class}: #{e.message}" }
|
|
69
|
+
end
|
|
49
70
|
@thread = Thread.new { run }
|
|
50
71
|
end
|
|
51
72
|
|
|
@@ -82,7 +103,7 @@ module Cogworker
|
|
|
82
103
|
def run
|
|
83
104
|
until @manager.stopping?
|
|
84
105
|
begin
|
|
85
|
-
tick unless @manager.quiet?
|
|
106
|
+
tick unless @manager.quiet? || !@ready.call
|
|
86
107
|
rescue StandardError => e
|
|
87
108
|
Cogworker.logger.error { "Periodic tick failed: #{e.class}: #{e.message}" }
|
|
88
109
|
end
|
|
@@ -91,15 +112,91 @@ module Cogworker
|
|
|
91
112
|
end
|
|
92
113
|
|
|
93
114
|
def tick
|
|
115
|
+
retry_pending_rollbacks
|
|
94
116
|
now = Time.now
|
|
95
117
|
@entries.each do |entry|
|
|
96
|
-
|
|
97
|
-
|
|
118
|
+
next if backing_off?(entry.pjid)
|
|
119
|
+
|
|
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}" }
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
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
|
|
98
159
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
160
|
+
# The usual reason a push fails — Redis unreachable — makes this fail
|
|
161
|
+
# too; it's then kept and retried at the start of every tick until it
|
|
162
|
+
# goes through (before that tick looks at the slot again), rather than
|
|
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)
|
|
171
|
+
Cogworker.config.redis do |c|
|
|
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])
|
|
175
|
+
end
|
|
176
|
+
true
|
|
177
|
+
rescue StandardError => e
|
|
178
|
+
Cogworker.logger.error { "periodic #{entry.pjid}: couldn't roll back slot #{slot} (will retry): #{e.class}: #{e.message}" }
|
|
179
|
+
@pending_rollbacks << [entry, slot, previous_slot, jid]
|
|
180
|
+
e # (truthy, but not `true`: the caller tells a lost connection from a failing rollback)
|
|
181
|
+
end
|
|
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.
|
|
186
|
+
def retry_pending_rollbacks
|
|
187
|
+
pending = @pending_rollbacks
|
|
188
|
+
@pending_rollbacks = []
|
|
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
|
|
103
200
|
end
|
|
104
201
|
end
|
|
105
202
|
|
|
@@ -114,17 +211,18 @@ module Cogworker
|
|
|
114
211
|
# persisted last_slot) still advances either way, exactly as it
|
|
115
212
|
# already did before this entry ever had a disabled state — it only
|
|
116
213
|
# stops this same tick loop from re-evaluating the same slot twice.
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
rescue StandardError => e
|
|
123
|
-
Cogworker.logger.error { "periodic #{entry.pjid}: couldn't roll back slot #{slot}: #{e.class}: #{e.message}" }
|
|
124
|
-
end
|
|
125
|
-
|
|
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).
|
|
126
219
|
def disabled?(entry)
|
|
127
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
|
|
128
226
|
end
|
|
129
227
|
|
|
130
228
|
def cron_for(entry)
|
|
@@ -137,7 +235,7 @@ module Cogworker
|
|
|
137
235
|
return nil if !@catch_up && priming_first_slot?(entry, slot)
|
|
138
236
|
|
|
139
237
|
result = Cogworker.config.redis do |c|
|
|
140
|
-
|
|
238
|
+
LuaScript.run(c, CLAIM_SCRIPT,
|
|
141
239
|
keys: [RedisKeys.periodic_running(entry.pjid), RedisKeys.periodic_last_slot(entry.pjid),
|
|
142
240
|
RedisKeys.periodic_lock(entry.pjid, slot)],
|
|
143
241
|
argv: [slot, entry.unique.to_s, LOCK_TTL, jid, RunningLock.queued_ttl])
|
|
@@ -183,8 +281,7 @@ module Cogworker
|
|
|
183
281
|
pushed = begin
|
|
184
282
|
Client.push(job)
|
|
185
283
|
rescue StandardError
|
|
186
|
-
|
|
187
|
-
rollback_claim(entry, slot, previous_slot)
|
|
284
|
+
rollback_claim(entry, slot, previous_slot, jid)
|
|
188
285
|
raise
|
|
189
286
|
end
|
|
190
287
|
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
|
|
|
@@ -69,33 +86,104 @@ module Cogworker
|
|
|
69
86
|
return fetcher.give_back(work) if @manager.stopping?
|
|
70
87
|
|
|
71
88
|
@manager.processor_busy!
|
|
89
|
+
@manager.job_started(work.raw_job)
|
|
72
90
|
@busy = true
|
|
73
|
-
|
|
91
|
+
@ran = nil
|
|
92
|
+
disposition = nil
|
|
74
93
|
begin
|
|
75
|
-
execute(work)
|
|
76
|
-
finished = true
|
|
94
|
+
disposition = execute(work)
|
|
77
95
|
ensure
|
|
78
96
|
@busy = false
|
|
79
97
|
@manager.processor_idle!
|
|
80
|
-
|
|
98
|
+
# `execute` raising leaves `disposition` unset: before the job ran
|
|
99
|
+
# that's a hand-back; once it ran, whatever its outcome dictates
|
|
100
|
+
# (`@ran`, set the moment `run_job` returns) — so an unexpected
|
|
101
|
+
# exception later in the bookkeeping (a bug, a misbehaving logger)
|
|
102
|
+
# can't re-queue a job that already ran.
|
|
103
|
+
settle(work, disposition || @ran || :give_back)
|
|
104
|
+
@manager.job_finished(work.raw_job)
|
|
81
105
|
end
|
|
82
106
|
end
|
|
83
107
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
108
|
+
def settle(work, disposition)
|
|
109
|
+
case disposition
|
|
110
|
+
when :acknowledge then acknowledge(work)
|
|
111
|
+
when :give_back then give_back(work)
|
|
112
|
+
else interrupt(work, *disposition.drop(1))
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Only for a job that hasn't run yet (see the class comment). If even
|
|
117
|
+
# this fails, it stays on the in-progress list, which `Manager#stop!` /
|
|
118
|
+
# orphan recovery / `ReliableFetch.reconcile` requeue later.
|
|
91
119
|
def give_back(work)
|
|
92
120
|
fetcher.give_back(work)
|
|
93
121
|
rescue StandardError => e
|
|
94
122
|
Cogworker.logger.error do
|
|
95
|
-
"couldn't hand back jid=#{jid_of(work)}
|
|
123
|
+
"couldn't hand back jid=#{jid_of(work)} (left in progress): #{e.class}: #{e.message}"
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# The job ran and failed, but recording that in retry/dead failed. Built
|
|
128
|
+
# from the original payload (always serializable — it came from JSON)
|
|
129
|
+
# plus the failure fields, never from the in-memory job hash: whatever
|
|
130
|
+
# made that one fail to serialize must not do it again here.
|
|
131
|
+
def interrupt(work, job, error)
|
|
132
|
+
payload = JSON.parse(work.raw_job)
|
|
133
|
+
count = payload['interrupted_count'].to_i + 1
|
|
134
|
+
# This attempt's number, from the payload as fetched — not from the
|
|
135
|
+
# in-memory job, whose count `route_failure` may not have got to.
|
|
136
|
+
attempt = payload['retry_count'].to_i + 1
|
|
137
|
+
payload.merge!('interrupted_count' => count, 'retry_count' => attempt,
|
|
138
|
+
'error_class' => error.class.name, 'error_message' => JobUtil.error_message(error),
|
|
139
|
+
'failed_at' => Time.now.to_f)
|
|
140
|
+
# No retries left (`retry: false`/`0`, or the last one used up) means
|
|
141
|
+
# no extra attempt either — straight to dead, like any terminal failure.
|
|
142
|
+
out_of_retries = attempt > JobUtil.max_retries(payload)
|
|
143
|
+
delay = INTERRUPT_DELAY * count
|
|
144
|
+
set, score = if out_of_retries || count > MAX_INTERRUPTS
|
|
145
|
+
[RedisKeys::DEAD, Time.now.to_f]
|
|
146
|
+
else
|
|
147
|
+
[RedisKeys::RETRY, Time.now.to_f + delay]
|
|
148
|
+
end
|
|
149
|
+
encoded = JSON.generate(payload)
|
|
150
|
+
begin
|
|
151
|
+
fetcher.interrupt(work, set, score, encoded)
|
|
152
|
+
rescue StandardError
|
|
153
|
+
# Remembered with its payload: this process's reconcile (and its
|
|
154
|
+
# shutdown) keep trying to file it — never re-run it as a stray.
|
|
155
|
+
@manager.settle_later(work.raw_job, [set, score, encoded]) if fetcher.is_a?(ReliableFetch)
|
|
156
|
+
raise
|
|
157
|
+
end
|
|
158
|
+
best_effort('lock extension') { extend_locks_for_retry(job, delay) } if set == RedisKeys::RETRY
|
|
159
|
+
reason = if set == RedisKeys::RETRY
|
|
160
|
+
"retrying in #{delay}s (interruption #{count} of at most #{MAX_INTERRUPTS})"
|
|
161
|
+
elsif out_of_retries
|
|
162
|
+
'moved to dead: no retries left'
|
|
163
|
+
else
|
|
164
|
+
"moved to dead: interrupted more than #{MAX_INTERRUPTS} times"
|
|
165
|
+
end
|
|
166
|
+
Cogworker.logger.error { "couldn't record the failure of jid=#{payload['jid']} (#{error.class}); #{reason}" }
|
|
167
|
+
rescue StandardError => e
|
|
168
|
+
# Whatever failed (even before anything was written), remember the job
|
|
169
|
+
# with the simplest possible dead entry, so reconcile files it — not
|
|
170
|
+
# leave it a stray to be re-run. (Already remembered if it got as far
|
|
171
|
+
# as the write.)
|
|
172
|
+
remember_for_dead(work, error) if fetcher.is_a?(ReliableFetch) && !@manager.pending_settlements.key?(work.raw_job)
|
|
173
|
+
Cogworker.logger.error do
|
|
174
|
+
"couldn't record or interrupt jid=#{jid_of(work)} (left in progress): #{e.class}: #{e.message}"
|
|
96
175
|
end
|
|
97
176
|
end
|
|
98
177
|
|
|
178
|
+
def remember_for_dead(work, error)
|
|
179
|
+
entry = JSON.generate('jid' => jid_of(work), 'error_class' => error.class.name.to_s,
|
|
180
|
+
'error_message' => JobUtil.error_message(error), 'failed_at' => Time.now.to_f,
|
|
181
|
+
'raw_payload' => JobUtil.safe_string(work.raw_job, 100_000))
|
|
182
|
+
@manager.settle_later(work.raw_job, [RedisKeys::DEAD, Time.now.to_f, entry])
|
|
183
|
+
rescue StandardError
|
|
184
|
+
nil
|
|
185
|
+
end
|
|
186
|
+
|
|
99
187
|
# A failed ack leaves an already-finished job on this process's own
|
|
100
188
|
# in-progress list, where `Manager#stop!` would later requeue it — a
|
|
101
189
|
# silent second run. Retried briefly, then logged with the jid so that
|
|
@@ -110,9 +198,12 @@ module Cogworker
|
|
|
110
198
|
sleep(0.1 * attempts)
|
|
111
199
|
retry
|
|
112
200
|
end
|
|
201
|
+
# Remembered, so this process's own reconcile finishes the ack later
|
|
202
|
+
# instead of mistaking the entry for a stray and re-running it.
|
|
203
|
+
@manager.settle_later(work.raw_job, :ack)
|
|
113
204
|
Cogworker.logger.error do
|
|
114
205
|
"couldn't acknowledge finished job jid=#{jid_of(work)} after #{attempts} attempts " \
|
|
115
|
-
"(it may run again
|
|
206
|
+
"(will retry; it may run again if this process stops first): #{e.class}: #{e.message}"
|
|
116
207
|
end
|
|
117
208
|
end
|
|
118
209
|
end
|
|
@@ -123,53 +214,86 @@ module Cogworker
|
|
|
123
214
|
'?'
|
|
124
215
|
end
|
|
125
216
|
|
|
217
|
+
# Returns how `settle` should dispose of the fetched job. If it raises
|
|
218
|
+
# instead, `process_one` falls back on `@ran` — set the moment the job
|
|
219
|
+
# has run — or, before that, on :give_back.
|
|
126
220
|
def execute(work)
|
|
127
221
|
job = parse_job(work)
|
|
128
|
-
return unless job
|
|
129
|
-
|
|
130
|
-
|
|
222
|
+
return :acknowledge unless job
|
|
223
|
+
|
|
224
|
+
# Best-effort: for the reliable fetch the in-progress list is what
|
|
225
|
+
# keeps the job safe; `cogworker:workers` only feeds the Workers tab
|
|
226
|
+
# (and BasicFetch's shutdown requeue). A failure here — even a
|
|
227
|
+
# deterministic one, the key holding the wrong type — used to hand
|
|
228
|
+
# the job back before it ever ran, over and over.
|
|
229
|
+
best_effort('register') { register_in_workers(work.queue, job) }
|
|
131
230
|
Cogworker.logger.info { "start: #{job['class']} jid=#{job['jid']}" }
|
|
132
|
-
|
|
133
231
|
begin
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
# would never reach them — this job would fail/retry/die with no
|
|
142
|
-
# History entry and no status update at all, silently. Catching it
|
|
143
|
-
# here and re-raising it as the chain's own "final block" gives
|
|
144
|
-
# every middleware the same crack at observing this failure as any
|
|
145
|
-
# perform-time one, with `worker` as `nil` in that case (every
|
|
146
|
-
# built-in server middleware ignores the `worker` arg entirely
|
|
147
|
-
# already; a custom one that needs a real instance simply can't act
|
|
148
|
-
# on this failure either way — there's no worker to act on).
|
|
149
|
-
resolution_error = nil
|
|
150
|
-
worker = begin
|
|
151
|
-
build_worker(job)
|
|
152
|
-
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
153
|
-
resolution_error = e
|
|
154
|
-
nil
|
|
155
|
-
end
|
|
232
|
+
error = run_job(work, job)
|
|
233
|
+
@ran = error ? [:interrupt, job, error] : :acknowledge
|
|
234
|
+
error ? finish_failure(job, error) : finish_success(job)
|
|
235
|
+
ensure
|
|
236
|
+
best_effort('deregister') { deregister_from_workers }
|
|
237
|
+
end
|
|
238
|
+
end
|
|
156
239
|
|
|
157
|
-
|
|
158
|
-
|
|
240
|
+
# The job's own outcome: nil, or what it (or its middleware) raised.
|
|
241
|
+
def run_job(work, job)
|
|
242
|
+
# `build_worker` itself can raise (most commonly `NameError`, e.g. a
|
|
243
|
+
# class the caller registered/enqueued but never actually defined —
|
|
244
|
+
# deliberately still routed through the *same* middleware chain
|
|
245
|
+
# below rather than caught here directly: `History::Middleware`/
|
|
246
|
+
# `Status::ServerMiddleware` (and any custom server middleware)
|
|
247
|
+
# only ever see a job by wrapping this `chain.invoke` call, so a
|
|
248
|
+
# resolution failure caught out here, before `invoke` ever runs,
|
|
249
|
+
# would never reach them — this job would fail/retry/die with no
|
|
250
|
+
# History entry and no status update at all, silently. Catching it
|
|
251
|
+
# here and re-raising it as the chain's own "final block" gives
|
|
252
|
+
# every middleware the same crack at observing this failure as any
|
|
253
|
+
# perform-time one, with `worker` as `nil` in that case (every
|
|
254
|
+
# built-in server middleware ignores the `worker` arg entirely
|
|
255
|
+
# already; a custom one that needs a real instance simply can't act
|
|
256
|
+
# on this failure either way — there's no worker to act on).
|
|
257
|
+
resolution_error = nil
|
|
258
|
+
worker = begin
|
|
259
|
+
build_worker(job)
|
|
260
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
261
|
+
resolution_error = e
|
|
262
|
+
nil
|
|
263
|
+
end
|
|
159
264
|
|
|
160
|
-
|
|
161
|
-
|
|
265
|
+
Cogworker.config.server_chain.invoke(worker, job, work.queue) do
|
|
266
|
+
raise resolution_error if resolution_error
|
|
267
|
+
|
|
268
|
+
worker.perform(*job['args'])
|
|
269
|
+
end
|
|
270
|
+
nil
|
|
271
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
272
|
+
e
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def finish_success(job)
|
|
276
|
+
best_effort('stats') do
|
|
162
277
|
Cogworker.config.redis { |c| c.incr(RedisKeys::STATS_PROCESSED) }
|
|
163
278
|
Throughput.record('processed')
|
|
164
|
-
|
|
165
|
-
|
|
279
|
+
end
|
|
280
|
+
Cogworker.logger.info { "done: #{job['class']} jid=#{job['jid']}" }
|
|
281
|
+
:acknowledge
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def finish_failure(job, error)
|
|
285
|
+
best_effort('stats') do
|
|
166
286
|
Cogworker.config.redis { |c| c.incr(RedisKeys::STATS_FAILED) }
|
|
167
287
|
Throughput.record('failed')
|
|
168
|
-
route_failure(job, e)
|
|
169
|
-
Cogworker.logger.warn { "fail: #{job['class']} jid=#{job['jid']}: #{e.class}: #{e&.message}" }
|
|
170
288
|
end
|
|
171
|
-
|
|
172
|
-
|
|
289
|
+
Cogworker.logger.warn { "fail: #{job['class']} jid=#{job['jid']}: #{error.class}: #{JobUtil.error_message(error)}" }
|
|
290
|
+
route_failure(job, error) ? :acknowledge : [:interrupt, job, error]
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def best_effort(what)
|
|
294
|
+
yield
|
|
295
|
+
rescue StandardError => e
|
|
296
|
+
Cogworker.logger.error { "#{what} failed (ignored): #{e.class}: #{e.message}" }
|
|
173
297
|
end
|
|
174
298
|
|
|
175
299
|
# A payload that isn't a JSON object goes straight to `dead`
|
|
@@ -199,7 +323,7 @@ module Cogworker
|
|
|
199
323
|
# Built on first use, not in the constructor (see Manager#fetch_class),
|
|
200
324
|
# and rebuilt after a fall-back to BasicFetch.
|
|
201
325
|
def fetcher
|
|
202
|
-
@fetcher ||= @manager.fetch_class.new(@manager.queues)
|
|
326
|
+
@fetcher ||= @manager.fetch_class.new(@manager.queues, stopping: -> { @manager.stopping? })
|
|
203
327
|
end
|
|
204
328
|
|
|
205
329
|
def build_worker(job)
|
|
@@ -209,24 +333,35 @@ module Cogworker
|
|
|
209
333
|
worker
|
|
210
334
|
end
|
|
211
335
|
|
|
336
|
+
# True once the failure is recorded in retry/dead — the one write that
|
|
337
|
+
# must happen; everything after it is best-effort. False if that write
|
|
338
|
+
# failed (`finish_failure` then interrupts the job instead).
|
|
212
339
|
def route_failure(job, error)
|
|
213
340
|
job['error_class'] = error.class.name
|
|
214
|
-
job['error_message'] = error
|
|
341
|
+
job['error_message'] = JobUtil.error_message(error)
|
|
215
342
|
job['failed_at'] ||= Time.now.to_f
|
|
216
343
|
|
|
217
344
|
max_retries = JobUtil.max_retries(job)
|
|
218
345
|
new_count = job['retry_count'].to_i + 1
|
|
219
346
|
job['retry_count'] = new_count
|
|
347
|
+
retrying = new_count <= max_retries
|
|
348
|
+
delay = retry_delay(new_count) if retrying
|
|
349
|
+
|
|
350
|
+
begin
|
|
351
|
+
payload = JSON.generate(job)
|
|
352
|
+
Cogworker.config.redis do |c|
|
|
353
|
+
retrying ? c.zadd(RedisKeys::RETRY, Time.now.to_f + delay, payload) : c.zadd(RedisKeys::DEAD, Time.now.to_f, payload)
|
|
354
|
+
end
|
|
355
|
+
rescue StandardError => e
|
|
356
|
+
Cogworker.logger.error { "couldn't record failure of jid=#{job['jid']}: #{e.class}: #{e.message}" }
|
|
357
|
+
return false
|
|
358
|
+
end
|
|
220
359
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
extend_locks_for_retry(job, delay)
|
|
225
|
-
Attempts.record(job['jid'], attempt: new_count, error: error, outcome: 'retrying')
|
|
226
|
-
else
|
|
227
|
-
Cogworker.config.redis { |c| c.zadd(RedisKeys::DEAD, Time.now.to_f, JSON.generate(job)) }
|
|
228
|
-
Attempts.record(job['jid'], attempt: new_count, error: error, outcome: 'dead')
|
|
360
|
+
best_effort('lock extension') { extend_locks_for_retry(job, delay) } if retrying
|
|
361
|
+
best_effort('attempts log') do
|
|
362
|
+
Attempts.record(job['jid'], attempt: new_count, error: error, outcome: retrying ? 'retrying' : 'dead')
|
|
229
363
|
end
|
|
364
|
+
true
|
|
230
365
|
end
|
|
231
366
|
|
|
232
367
|
# A job waiting out its retry backoff still holds its `until_executed`
|
data/lib/cogworker/redis_keys.rb
CHANGED
|
@@ -17,6 +17,15 @@ module Cogworker
|
|
|
17
17
|
# No TTL, unlike `process(identity)`: it's what tells "dead for a
|
|
18
18
|
# while" apart from "missed a few beats" (Config#orphan_threshold).
|
|
19
19
|
LAST_BEAT = 'cogworker:last_beat'
|
|
20
|
+
# Jobs recovered from dead processes more than `config.max_orphanings` times,
|
|
21
|
+
# parked by REQUEUE_SCRIPT for Ruby to file in `dead` (see
|
|
22
|
+
# ReliableFetch.bury_repeat_orphans).
|
|
23
|
+
REPEAT_ORPHANS = 'cogworker:repeat_orphans'
|
|
24
|
+
ORPHANINGS_PREFIX = 'cogworker:orphanings:'
|
|
25
|
+
# Jobs that ran and failed but couldn't be filed in retry/dead, handed
|
|
26
|
+
# over by a process shutting down (JSON {set, score, payload} each) —
|
|
27
|
+
# see ReliableFetch.park_unsettled / file_unsettled.
|
|
28
|
+
UNSETTLED = 'cogworker:unsettled'
|
|
20
29
|
QUEUES = 'cogworker:queues'
|
|
21
30
|
SCHEDULE = 'cogworker:schedule'
|
|
22
31
|
RETRY = 'cogworker:retry'
|