cogworker 0.4.1 → 0.5.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/LICENSE.txt +21 -0
- data/README.md +596 -0
- data/lib/cogworker/basic_fetch.rb +4 -3
- data/lib/cogworker/best_effort.rb +23 -0
- data/lib/cogworker/cli.rb +3 -2
- data/lib/cogworker/client.rb +17 -4
- data/lib/cogworker/config.rb +16 -1
- data/lib/cogworker/deferred_releases.rb +46 -0
- data/lib/cogworker/heartbeat.rb +72 -15
- data/lib/cogworker/history/middleware.rb +9 -5
- data/lib/cogworker/job_util.rb +39 -4
- data/lib/cogworker/key_guard.rb +127 -0
- data/lib/cogworker/launcher.rb +6 -2
- data/lib/cogworker/lua_script.rb +3 -3
- data/lib/cogworker/manager.rb +76 -1
- data/lib/cogworker/periodic/claim.lua +5 -2
- data/lib/cogworker/periodic/release_middleware.rb +13 -2
- data/lib/cogworker/periodic/running_lock.rb +12 -0
- data/lib/cogworker/periodic/ticker.rb +148 -40
- data/lib/cogworker/process_set.rb +3 -0
- data/lib/cogworker/processor.rb +94 -19
- data/lib/cogworker/redis_errors.rb +93 -0
- data/lib/cogworker/redis_keys.rb +13 -0
- data/lib/cogworker/reliable_fetch.rb +477 -60
- data/lib/cogworker/scheduled.rb +101 -18
- data/lib/cogworker/status/server_middleware.rb +13 -7
- data/lib/cogworker/swarm.rb +2 -0
- data/lib/cogworker/unique_jobs/release_middleware.rb +16 -5
- data/lib/cogworker/version.rb +1 -1
- data/lib/cogworker/web/layout.rb +3 -1
- data/lib/cogworker/web/routes/jobs.rb +4 -4
- data/lib/cogworker/web/routes/overview.rb +4 -2
- data/lib/cogworker/work_set.rb +3 -0
- data/lib/cogworker.rb +1 -1
- metadata +7 -1
|
@@ -23,14 +23,25 @@ module Cogworker
|
|
|
23
23
|
begin
|
|
24
24
|
yield
|
|
25
25
|
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
26
|
-
|
|
26
|
+
release(pjid, job['jid']) if JobUtil.terminal_failure?(job)
|
|
27
27
|
raise e
|
|
28
28
|
end
|
|
29
|
-
|
|
29
|
+
release(pjid, job['jid'])
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
private
|
|
33
33
|
|
|
34
|
+
# Best-effort — a release that fails (Redis away) never turns the
|
|
35
|
+
# finished run into a failure — and retried later (DeferredReleases)
|
|
36
|
+
# rather than left to the lock's TTL.
|
|
37
|
+
def release(pjid, jid)
|
|
38
|
+
released = BestEffort.call('Periodic lock') do
|
|
39
|
+
RunningLock.release(pjid, jid)
|
|
40
|
+
true
|
|
41
|
+
end
|
|
42
|
+
DeferredReleases.add(RedisKeys.periodic_running(pjid), jid) unless released
|
|
43
|
+
end
|
|
44
|
+
|
|
34
45
|
# Best-effort: failing here must not stop the job from running (it
|
|
35
46
|
# would be routed to retry/dead as if it had failed). If it does fail,
|
|
36
47
|
# the lock just keeps its longer queued TTL until this process's next
|
|
@@ -61,6 +61,18 @@ module Cogworker
|
|
|
61
61
|
OwnedKey.expire(RedisKeys.periodic_running(pjid), jid, ttl, conn)
|
|
62
62
|
end
|
|
63
63
|
|
|
64
|
+
RETAKE_SCRIPT = RETAKE_FUNCTION + <<~LUA
|
|
65
|
+
retake_running_lock(KEYS[1], ARGV[1], ARGV[2])
|
|
66
|
+
return 1
|
|
67
|
+
LUA
|
|
68
|
+
|
|
69
|
+
# Extends the lock if still `jid`'s, takes it if it's gone, leaves it
|
|
70
|
+
# if another run holds it — for a run still going whose lock may have
|
|
71
|
+
# lapsed (Redis away longer than `active_ttl`).
|
|
72
|
+
def retake(pjid, jid, ttl, conn)
|
|
73
|
+
LuaScript.run(conn, RETAKE_SCRIPT, keys: [RedisKeys.periodic_running(pjid)], argv: [jid, ttl.to_f.ceil])
|
|
74
|
+
end
|
|
75
|
+
|
|
64
76
|
def release(pjid, jid, conn = nil)
|
|
65
77
|
OwnedKey.delete(RedisKeys.periodic_running(pjid), jid, conn)
|
|
66
78
|
end
|
|
@@ -20,12 +20,16 @@ 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
|
-
if redis.
|
|
32
|
+
if redis.pcall("GET", KEYS[1]) == ARGV[1] then
|
|
29
33
|
if ARGV[2] == "" then
|
|
30
34
|
redis.call("SET", KEYS[1], tostring(tonumber(ARGV[1]) - 1))
|
|
31
35
|
else
|
|
@@ -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,20 @@ module Cogworker
|
|
|
44
54
|
@last_checked_slot = {}
|
|
45
55
|
@cron_cache = {}
|
|
46
56
|
@pending_rollbacks = []
|
|
57
|
+
@blocked_since = {} # pjid => [slot, monotonic time it was first found blocked]
|
|
58
|
+
@failures = Hash.new(0) # pjid => consecutive failed ticks
|
|
59
|
+
@retry_at = {} # pjid => monotonic time it may be tried again
|
|
47
60
|
end
|
|
48
61
|
|
|
62
|
+
# Publishing is only for restart survival and the Web UI: its failure
|
|
63
|
+
# (Redis down at boot, the key holding the wrong type) is logged, and
|
|
64
|
+
# must not take the process down with it.
|
|
49
65
|
def start!
|
|
50
|
-
|
|
66
|
+
begin
|
|
67
|
+
publish_schedule!
|
|
68
|
+
rescue StandardError => e
|
|
69
|
+
Cogworker.logger.error { "Periodic schedule not published: #{e.class}: #{e.message}" }
|
|
70
|
+
end
|
|
51
71
|
@thread = Thread.new { run }
|
|
52
72
|
end
|
|
53
73
|
|
|
@@ -94,72 +114,161 @@ module Cogworker
|
|
|
94
114
|
|
|
95
115
|
def tick
|
|
96
116
|
retry_pending_rollbacks
|
|
117
|
+
# This process's own delayed lock releases first (see BLOCKED_GRACE).
|
|
118
|
+
BestEffort.call('Deferred lock releases') { Cogworker.config.redis { |c| DeferredReleases.retry_all(c) } }
|
|
97
119
|
now = Time.now
|
|
98
120
|
@entries.each do |entry|
|
|
99
|
-
|
|
100
|
-
|
|
121
|
+
next if backing_off?(entry.pjid)
|
|
122
|
+
|
|
123
|
+
next if tick_entry(entry, now) == :skipped # (no Redis call made: proves nothing)
|
|
101
124
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
125
|
+
@failures.delete(entry.pjid)
|
|
126
|
+
RedisErrors.recovered('Periodic tick')
|
|
127
|
+
rescue StandardError => e
|
|
128
|
+
# Per entry: one entry failing (its keys of the wrong type, its
|
|
129
|
+
# push failing) used to skip every entry after it, on every tick.
|
|
130
|
+
# Its slot isn't recorded as checked, so it's retried — after an
|
|
131
|
+
# exponential pause (`back_off`), not on every 5s tick, which made a
|
|
132
|
+
# deterministically failing entry hammer Redis and the log. Not for
|
|
133
|
+
# a lost connection, though: that's Redis being away, not the
|
|
134
|
+
# entry failing — backing off every entry on it left cron idle for
|
|
135
|
+
# minutes after Redis came back.
|
|
136
|
+
delay = RedisErrors.unavailable?(e) ? TICK_INTERVAL : back_off(entry.pjid)
|
|
137
|
+
RedisErrors.report('Periodic tick', e) { "#{entry.pjid} failed (next try in #{delay}s)" }
|
|
106
138
|
end
|
|
107
139
|
end
|
|
108
140
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
#
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
141
|
+
BACKOFF_MAX = 300
|
|
142
|
+
|
|
143
|
+
def backing_off?(key)
|
|
144
|
+
(at = @retry_at[key]) && ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) < at
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# 5s, 10s, 20s … up to BACKOFF_MAX; reset by the next success.
|
|
148
|
+
def back_off(key)
|
|
149
|
+
@failures[key] += 1
|
|
150
|
+
delay = [TICK_INTERVAL * (2**(@failures[key] - 1)), BACKOFF_MAX].min
|
|
151
|
+
@retry_at[key] = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + delay
|
|
152
|
+
delay
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def tick_entry(entry, now)
|
|
156
|
+
slot = cron_for(entry).previous_time(now).to_i
|
|
157
|
+
return :skipped if @last_checked_slot[entry.pjid] == slot
|
|
158
|
+
|
|
159
|
+
jid = SecureRandom.hex(12)
|
|
160
|
+
previous = claim(entry, slot, jid) unless disabled?(entry)
|
|
161
|
+
return if previous == :blocked && still_blocked_in_grace?(entry.pjid, slot)
|
|
162
|
+
|
|
163
|
+
enqueue(entry, slot, jid, previous) if previous && previous != :blocked
|
|
164
|
+
@last_checked_slot[entry.pjid] = slot
|
|
165
|
+
@blocked_since.delete(entry.pjid)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# A slot blocked by the entry's running lock is retried for
|
|
169
|
+
# BLOCKED_GRACE before it counts as skipped. After an outage, the lock
|
|
170
|
+
# may still be held only because its finished run's release was
|
|
171
|
+
# deferred (DeferredReleases — retried on the next heartbeat, in
|
|
172
|
+
# whichever process ran it); marking the slot checked straight away
|
|
173
|
+
# lost it, in every process, until the entry's next slot — an hour,
|
|
174
|
+
# for an hourly entry. A run genuinely still going keeps the lock past
|
|
175
|
+
# the grace, and its slot is skipped as `until_executed` intends.
|
|
176
|
+
BLOCKED_GRACE = 3 * Heartbeat::INTERVAL
|
|
177
|
+
|
|
178
|
+
def still_blocked_in_grace?(pjid, slot)
|
|
179
|
+
now = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
180
|
+
since_slot, since = @blocked_since[pjid]
|
|
181
|
+
@blocked_since[pjid] = [slot, since = now] unless since_slot == slot
|
|
182
|
+
now - since < BLOCKED_GRACE
|
|
183
|
+
end
|
|
184
|
+
|
|
120
185
|
# The usual reason a push fails — Redis unreachable — makes this fail
|
|
121
186
|
# too; it's then kept and retried at the start of every tick until it
|
|
122
187
|
# 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
|
-
|
|
188
|
+
# just logged and the slot lost. Returns true, or the error. (A push
|
|
189
|
+
# that timed out *after* Redis had applied it is the one case this
|
|
190
|
+
# turns into a second run of the slot: indistinguishable from one that
|
|
191
|
+
# never arrived.) Pending
|
|
192
|
+
# rollbacks live in this process's memory only: one still pending when
|
|
193
|
+
# the process restarts is lost — that slot is skipped, and an
|
|
194
|
+
# `until_executed` entry stays locked until its lock's TTL.
|
|
195
|
+
def rollback_claim(entry, slot, previous_slot, jid)
|
|
127
196
|
Cogworker.config.redis do |c|
|
|
128
|
-
keys = [RedisKeys.periodic_last_slot(entry.pjid), RedisKeys.periodic_lock(entry.pjid, slot)
|
|
129
|
-
|
|
197
|
+
keys = [RedisKeys.periodic_last_slot(entry.pjid), RedisKeys.periodic_lock(entry.pjid, slot),
|
|
198
|
+
RedisKeys.periodic_running(entry.pjid)]
|
|
199
|
+
LuaScript.run(c, ROLLBACK_SCRIPT, keys: keys, argv: [slot, previous_slot, jid])
|
|
130
200
|
end
|
|
131
201
|
true
|
|
132
202
|
rescue StandardError => e
|
|
133
|
-
|
|
134
|
-
@pending_rollbacks << [entry, slot, previous_slot]
|
|
135
|
-
|
|
203
|
+
RedisErrors.report('Periodic rollback', e) { "#{entry.pjid}: couldn't roll back slot #{slot} (will retry)" }
|
|
204
|
+
@pending_rollbacks << [entry, slot, previous_slot, jid]
|
|
205
|
+
e # (truthy, but not `true`: the caller tells a lost connection from a failing rollback)
|
|
136
206
|
end
|
|
137
207
|
|
|
208
|
+
# With the same backoff as a failing entry (keyed by the rollback): a
|
|
209
|
+
# rollback that keeps failing is retried less and less often, but
|
|
210
|
+
# always before that entry's slot is looked at again.
|
|
138
211
|
def retry_pending_rollbacks
|
|
139
212
|
pending = @pending_rollbacks
|
|
140
213
|
@pending_rollbacks = []
|
|
141
|
-
pending.each
|
|
214
|
+
pending.each do |args|
|
|
215
|
+
key = "rollback:#{args[0].pjid}:#{args[1]}"
|
|
216
|
+
next @pending_rollbacks << args if backing_off?(key)
|
|
217
|
+
|
|
218
|
+
result = rollback_claim(*args)
|
|
219
|
+
if result == true
|
|
220
|
+
@failures.delete(key)
|
|
221
|
+
@retry_at.delete(key)
|
|
222
|
+
RedisErrors.recovered('Periodic rollback')
|
|
223
|
+
elsif !RedisErrors.unavailable?(result)
|
|
224
|
+
back_off(key) # only a rollback failing on its own backs off; see #tick
|
|
225
|
+
end
|
|
226
|
+
end
|
|
142
227
|
end
|
|
143
228
|
|
|
229
|
+
# Web UI "Disable" (`Routes::Schedules`) — skips the claim/enqueue
|
|
230
|
+
# step entirely, short-circuiting before `claim` even runs, so
|
|
231
|
+
# neither `periodic:last_slot:<pjid>` nor the per-slot lock advance
|
|
232
|
+
# while disabled: the Web UI's own "LastRun" column keeps showing the
|
|
233
|
+
# last time it *actually* ran, not a due-but-skipped slot, and
|
|
234
|
+
# re-enabling doesn't trigger a catch-up burst for every slot that
|
|
235
|
+
# was silently skipped in between. `@last_checked_slot` (this one
|
|
236
|
+
# Ticker instance's own in-memory dedup, unrelated to the Redis-
|
|
237
|
+
# persisted last_slot) still advances either way, exactly as it
|
|
238
|
+
# already did before this entry ever had a disabled state — it only
|
|
239
|
+
# stops this same tick loop from re-evaluating the same slot twice.
|
|
240
|
+
#
|
|
241
|
+
# A disabled-set that can't be read (the key holding the wrong type)
|
|
242
|
+
# counts as "not disabled", logged: it used to fail every entry's tick,
|
|
243
|
+
# stopping all cron. Losing a Disable is the lesser evil. A dropped
|
|
244
|
+
# connection still raises (the tick is then retried as a whole).
|
|
144
245
|
def disabled?(entry)
|
|
145
246
|
Cogworker.config.redis { |c| c.sismember(RedisKeys::PERIODIC_DISABLED, entry.pjid) }
|
|
247
|
+
rescue StandardError => e
|
|
248
|
+
raise if RedisErrors.unavailable?(e)
|
|
249
|
+
|
|
250
|
+
Cogworker.logger.error { "Periodic: can't read #{RedisKeys::PERIODIC_DISABLED} (#{e.class}: #{e.message}); treating #{entry.pjid} as enabled" }
|
|
251
|
+
false
|
|
146
252
|
end
|
|
147
253
|
|
|
148
254
|
def cron_for(entry)
|
|
149
255
|
@cron_cache[entry.pjid] ||= Fugit::Cron.parse(entry.cron)
|
|
150
256
|
end
|
|
151
257
|
|
|
152
|
-
# nil if this process didn't win the slot
|
|
153
|
-
#
|
|
258
|
+
# nil if this process didn't win the slot, :blocked if the entry's
|
|
259
|
+
# running lock stopped it; otherwise the previous `last_slot` value
|
|
260
|
+
# ("" if none), for #rollback_claim.
|
|
154
261
|
def claim(entry, slot, jid)
|
|
155
262
|
return nil if !@catch_up && priming_first_slot?(entry, slot)
|
|
156
263
|
|
|
157
264
|
result = Cogworker.config.redis do |c|
|
|
158
265
|
LuaScript.run(c, CLAIM_SCRIPT,
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
266
|
+
keys: [RedisKeys.periodic_running(entry.pjid), RedisKeys.periodic_last_slot(entry.pjid),
|
|
267
|
+
RedisKeys.periodic_lock(entry.pjid, slot)],
|
|
268
|
+
argv: [slot, entry.unique.to_s, LOCK_TTL, jid, RunningLock.queued_ttl])
|
|
162
269
|
end
|
|
270
|
+
return :blocked if result == 2
|
|
271
|
+
|
|
163
272
|
result.is_a?(Array) ? result[1].to_s : nil
|
|
164
273
|
end
|
|
165
274
|
|
|
@@ -201,8 +310,7 @@ module Cogworker
|
|
|
201
310
|
pushed = begin
|
|
202
311
|
Client.push(job)
|
|
203
312
|
rescue StandardError
|
|
204
|
-
|
|
205
|
-
rollback_claim(entry, slot, previous_slot)
|
|
313
|
+
rollback_claim(entry, slot, previous_slot, jid)
|
|
206
314
|
raise
|
|
207
315
|
end
|
|
208
316
|
return unless entry.until_executed?
|
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
require 'json'
|
|
4
4
|
|
|
5
5
|
module Cogworker
|
|
6
|
+
# Every live worker process, as published by its Heartbeat — one
|
|
7
|
+
# Cogworker::Process per `cogworker:processes` member whose presence key
|
|
8
|
+
# still exists (stale members are pruned while iterating).
|
|
6
9
|
class ProcessSet
|
|
7
10
|
include Enumerable
|
|
8
11
|
|
data/lib/cogworker/processor.rb
CHANGED
|
@@ -56,9 +56,9 @@ module Cogworker
|
|
|
56
56
|
def run
|
|
57
57
|
until @manager.stopping?
|
|
58
58
|
begin
|
|
59
|
-
process_one
|
|
59
|
+
RedisErrors.recovered('Processor') unless process_one == :idle
|
|
60
60
|
rescue StandardError => e
|
|
61
|
-
|
|
61
|
+
RedisErrors.report('Processor', e)
|
|
62
62
|
sleep(ERROR_BACKOFF)
|
|
63
63
|
end
|
|
64
64
|
end
|
|
@@ -67,7 +67,7 @@ module Cogworker
|
|
|
67
67
|
def process_one
|
|
68
68
|
if @manager.quiet?
|
|
69
69
|
sleep(0.5)
|
|
70
|
-
return
|
|
70
|
+
return :idle # (no Redis call made)
|
|
71
71
|
end
|
|
72
72
|
|
|
73
73
|
work = begin
|
|
@@ -86,14 +86,24 @@ module Cogworker
|
|
|
86
86
|
return fetcher.give_back(work) if @manager.stopping?
|
|
87
87
|
|
|
88
88
|
@manager.processor_busy!
|
|
89
|
+
@manager.job_started(work.raw_job)
|
|
89
90
|
@busy = true
|
|
90
|
-
|
|
91
|
+
@ran = nil
|
|
92
|
+
@performed = false
|
|
93
|
+
disposition = nil
|
|
91
94
|
begin
|
|
92
95
|
disposition = execute(work)
|
|
93
96
|
ensure
|
|
94
97
|
@busy = false
|
|
98
|
+
@manager.job_performed(work.raw_job) unless @performed
|
|
95
99
|
@manager.processor_idle!
|
|
96
|
-
|
|
100
|
+
# `execute` raising leaves `disposition` unset: before the job ran
|
|
101
|
+
# that's a hand-back; once it ran, whatever its outcome dictates
|
|
102
|
+
# (`@ran`, set the moment `run_job` returns) — so an unexpected
|
|
103
|
+
# exception later in the bookkeeping (a bug, a misbehaving logger)
|
|
104
|
+
# can't re-queue a job that already ran.
|
|
105
|
+
settle(work, disposition || @ran || :give_back)
|
|
106
|
+
@manager.job_finished(work.raw_job)
|
|
97
107
|
end
|
|
98
108
|
end
|
|
99
109
|
|
|
@@ -123,25 +133,65 @@ module Cogworker
|
|
|
123
133
|
def interrupt(work, job, error)
|
|
124
134
|
payload = JSON.parse(work.raw_job)
|
|
125
135
|
count = payload['interrupted_count'].to_i + 1
|
|
126
|
-
|
|
136
|
+
# This attempt's number, from the payload as fetched — not from the
|
|
137
|
+
# in-memory job, whose count `route_failure` may not have got to.
|
|
138
|
+
attempt = payload['retry_count'].to_i + 1
|
|
139
|
+
payload.merge!('interrupted_count' => count, 'retry_count' => attempt,
|
|
127
140
|
'error_class' => error.class.name, 'error_message' => JobUtil.error_message(error),
|
|
128
141
|
'failed_at' => Time.now.to_f)
|
|
129
|
-
|
|
142
|
+
# No retries left (`retry: false`/`0`, or the last one used up) means
|
|
143
|
+
# no extra attempt either — straight to dead, like any terminal failure.
|
|
144
|
+
out_of_retries = attempt > JobUtil.max_retries(payload)
|
|
145
|
+
delay = INTERRUPT_DELAY * count
|
|
146
|
+
set, score = if out_of_retries || count > MAX_INTERRUPTS
|
|
130
147
|
[RedisKeys::DEAD, Time.now.to_f]
|
|
131
148
|
else
|
|
132
|
-
[RedisKeys::RETRY, Time.now.to_f +
|
|
149
|
+
[RedisKeys::RETRY, Time.now.to_f + delay]
|
|
133
150
|
end
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
151
|
+
encoded = JSON.generate(payload)
|
|
152
|
+
begin
|
|
153
|
+
fetcher.interrupt(work, set, score, encoded)
|
|
154
|
+
rescue StandardError
|
|
155
|
+
# Remembered with its payload: this process's reconcile (and its
|
|
156
|
+
# shutdown) keep trying to file it — never re-run it as a stray.
|
|
157
|
+
@manager.settle_later(work.raw_job, [set, score, encoded]) if fetcher.is_a?(ReliableFetch)
|
|
158
|
+
raise
|
|
138
159
|
end
|
|
160
|
+
if set == RedisKeys::RETRY
|
|
161
|
+
best_effort('lock extension') { extend_locks_for_retry(job, delay) }
|
|
162
|
+
else
|
|
163
|
+
best_effort('lock release') { release_locks(payload) }
|
|
164
|
+
end
|
|
165
|
+
reason = if set == RedisKeys::RETRY
|
|
166
|
+
"retrying in #{delay}s (interruption #{count} of at most #{MAX_INTERRUPTS})"
|
|
167
|
+
elsif out_of_retries
|
|
168
|
+
'moved to dead: no retries left'
|
|
169
|
+
else
|
|
170
|
+
"moved to dead: interrupted more than #{MAX_INTERRUPTS} times"
|
|
171
|
+
end
|
|
172
|
+
Cogworker.logger.error { "couldn't record the failure of jid=#{payload['jid']} (#{error.class}); #{reason}" }
|
|
139
173
|
rescue StandardError => e
|
|
174
|
+
# Whatever failed (even before anything was written), remember the job
|
|
175
|
+
# with the simplest possible dead entry, so reconcile files it — not
|
|
176
|
+
# leave it a stray to be re-run. (Already remembered if it got as far
|
|
177
|
+
# as the write.)
|
|
178
|
+
remember_for_dead(work, error) if fetcher.is_a?(ReliableFetch) && !@manager.pending_settlements.key?(work.raw_job)
|
|
140
179
|
Cogworker.logger.error do
|
|
141
180
|
"couldn't record or interrupt jid=#{jid_of(work)} (left in progress): #{e.class}: #{e.message}"
|
|
142
181
|
end
|
|
143
182
|
end
|
|
144
183
|
|
|
184
|
+
def remember_for_dead(work, error)
|
|
185
|
+
# The original job's own fields where readable (so filing it releases
|
|
186
|
+
# its locks); just enough to show in Dead otherwise.
|
|
187
|
+
base = ReliableFetch.parse_hash(work.raw_job) || { 'raw_payload' => JobUtil.safe_string(work.raw_job, 100_000) }
|
|
188
|
+
entry = JSON.generate(base.merge('jid' => jid_of(work), 'error_class' => error.class.name.to_s,
|
|
189
|
+
'error_message' => JobUtil.error_message(error), 'failed_at' => Time.now.to_f))
|
|
190
|
+
@manager.settle_later(work.raw_job, [RedisKeys::DEAD, Time.now.to_f, entry])
|
|
191
|
+
rescue StandardError
|
|
192
|
+
nil
|
|
193
|
+
end
|
|
194
|
+
|
|
145
195
|
# A failed ack leaves an already-finished job on this process's own
|
|
146
196
|
# in-progress list, where `Manager#stop!` would later requeue it — a
|
|
147
197
|
# silent second run. Retried briefly, then logged with the jid so that
|
|
@@ -156,9 +206,12 @@ module Cogworker
|
|
|
156
206
|
sleep(0.1 * attempts)
|
|
157
207
|
retry
|
|
158
208
|
end
|
|
209
|
+
# Remembered, so this process's own reconcile finishes the ack later
|
|
210
|
+
# instead of mistaking the entry for a stray and re-running it.
|
|
211
|
+
@manager.settle_later(work.raw_job, :ack)
|
|
159
212
|
Cogworker.logger.error do
|
|
160
213
|
"couldn't acknowledge finished job jid=#{jid_of(work)} after #{attempts} attempts " \
|
|
161
|
-
"(it may run again
|
|
214
|
+
"(will retry; it may run again if this process stops first): #{e.class}: #{e.message}"
|
|
162
215
|
end
|
|
163
216
|
end
|
|
164
217
|
end
|
|
@@ -169,17 +222,25 @@ module Cogworker
|
|
|
169
222
|
'?'
|
|
170
223
|
end
|
|
171
224
|
|
|
172
|
-
# Returns how `settle` should dispose of the fetched job
|
|
173
|
-
#
|
|
174
|
-
#
|
|
225
|
+
# Returns how `settle` should dispose of the fetched job. If it raises
|
|
226
|
+
# instead, `process_one` falls back on `@ran` — set the moment the job
|
|
227
|
+
# has run — or, before that, on :give_back.
|
|
175
228
|
def execute(work)
|
|
176
229
|
job = parse_job(work)
|
|
177
230
|
return :acknowledge unless job
|
|
178
231
|
|
|
179
|
-
|
|
232
|
+
# Best-effort: for the reliable fetch the in-progress list is what
|
|
233
|
+
# keeps the job safe; `cogworker:workers` only feeds the Workers tab
|
|
234
|
+
# (and BasicFetch's shutdown requeue). A failure here — even a
|
|
235
|
+
# deterministic one, the key holding the wrong type — used to hand
|
|
236
|
+
# the job back before it ever ran, over and over.
|
|
237
|
+
best_effort('register') { register_in_workers(work.queue, job) }
|
|
180
238
|
Cogworker.logger.info { "start: #{job['class']} jid=#{job['jid']}" }
|
|
181
239
|
begin
|
|
182
240
|
error = run_job(work, job)
|
|
241
|
+
@manager.job_performed(work.raw_job)
|
|
242
|
+
@performed = true
|
|
243
|
+
@ran = error ? [:interrupt, job, error] : :acknowledge
|
|
183
244
|
error ? finish_failure(job, error) : finish_success(job)
|
|
184
245
|
ensure
|
|
185
246
|
best_effort('deregister') { deregister_from_workers }
|
|
@@ -235,7 +296,9 @@ module Cogworker
|
|
|
235
296
|
Cogworker.config.redis { |c| c.incr(RedisKeys::STATS_FAILED) }
|
|
236
297
|
Throughput.record('failed')
|
|
237
298
|
end
|
|
238
|
-
Cogworker.logger.warn
|
|
299
|
+
Cogworker.logger.warn do
|
|
300
|
+
"fail: #{job['class']} jid=#{job['jid']}: #{error.class}: #{JobUtil.error_message(error)}"
|
|
301
|
+
end
|
|
239
302
|
route_failure(job, error) ? :acknowledge : [:interrupt, job, error]
|
|
240
303
|
end
|
|
241
304
|
|
|
@@ -299,7 +362,12 @@ module Cogworker
|
|
|
299
362
|
begin
|
|
300
363
|
payload = JSON.generate(job)
|
|
301
364
|
Cogworker.config.redis do |c|
|
|
302
|
-
retrying
|
|
365
|
+
if retrying
|
|
366
|
+
c.zadd(RedisKeys::RETRY, Time.now.to_f + delay,
|
|
367
|
+
payload)
|
|
368
|
+
else
|
|
369
|
+
c.zadd(RedisKeys::DEAD, Time.now.to_f, payload)
|
|
370
|
+
end
|
|
303
371
|
end
|
|
304
372
|
rescue StandardError => e
|
|
305
373
|
Cogworker.logger.error { "couldn't record failure of jid=#{job['jid']}: #{e.class}: #{e.message}" }
|
|
@@ -329,6 +397,13 @@ module Cogworker
|
|
|
329
397
|
end
|
|
330
398
|
end
|
|
331
399
|
|
|
400
|
+
# Into dead by way of `interrupt` — possibly with retries still left,
|
|
401
|
+
# so the middleware that normally releases a terminal failure's locks
|
|
402
|
+
# didn't: release them here (owner-checked).
|
|
403
|
+
def release_locks(job)
|
|
404
|
+
Cogworker.config.redis { |c| JobUtil.release_terminal_locks(c, job) }
|
|
405
|
+
end
|
|
406
|
+
|
|
332
407
|
# Grows with the retry count, with jitter to avoid a thundering herd of
|
|
333
408
|
# retries all landing on the same second.
|
|
334
409
|
def retry_delay(count)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cogworker
|
|
4
|
+
# Telling "Redis is unavailable right now" from every other error, and
|
|
5
|
+
# keeping the log readable while it is.
|
|
6
|
+
#
|
|
7
|
+
# Unavailable means a lost connection, or one of the replies a server
|
|
8
|
+
# gives while it can't serve writes for a while — mid-failover (READONLY,
|
|
9
|
+
# MASTERDOWN), still loading its data (LOADING), or busy with a script
|
|
10
|
+
# (BUSY, TRYAGAIN). None of those say anything about the job, entry or key
|
|
11
|
+
# being worked on, so nothing should back off, count a failure, or give up
|
|
12
|
+
# on them; they just mean "try again shortly".
|
|
13
|
+
#
|
|
14
|
+
# `report` logs such errors once per `site` (a short name of the place
|
|
15
|
+
# they come from) as they start, then a one-line summary at most every
|
|
16
|
+
# SUMMARY_INTERVAL, and `recovered(site)` logs once that it's over. A 6
|
|
17
|
+
# minute outage used to produce thousands of identical lines. Any other
|
|
18
|
+
# error is logged in full, every time.
|
|
19
|
+
module RedisErrors
|
|
20
|
+
UNAVAILABLE_REPLIES = %w[READONLY LOADING MASTERDOWN TRYAGAIN BUSY].freeze
|
|
21
|
+
SUMMARY_INTERVAL = 60
|
|
22
|
+
SCRIPT_WRAPPED = /-(?:#{UNAVAILABLE_REPLIES.join('|')})(?=\s|\z)/
|
|
23
|
+
|
|
24
|
+
@mutex = Mutex.new
|
|
25
|
+
@outages = {} # site => { count:, since:, summarized_at:, error: }
|
|
26
|
+
|
|
27
|
+
class << self
|
|
28
|
+
def unavailable?(error)
|
|
29
|
+
return true if error.is_a?(Redis::BaseConnectionError)
|
|
30
|
+
# redis-rb 5's pub/sub path can surface redis-client's own error class.
|
|
31
|
+
return true if defined?(RedisClient::ConnectionError) && error.is_a?(RedisClient::ConnectionError)
|
|
32
|
+
|
|
33
|
+
return false unless error.is_a?(Redis::CommandError)
|
|
34
|
+
|
|
35
|
+
message = error.message.to_s
|
|
36
|
+
# The reply's code is its whole first word — BUSYKEY or BUSYGROUP
|
|
37
|
+
# aren't BUSY. Inside a script, Redis 6.x wraps the code instead:
|
|
38
|
+
# "ERR Error running script (...): ... -READONLY You can't ...".
|
|
39
|
+
UNAVAILABLE_REPLIES.include?(message[/\A\S+/]) ||
|
|
40
|
+
(message.include?('Error running script') && message.match?(SCRIPT_WRAPPED))
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Logs `error` at `site`; the block, if given, adds context to the
|
|
44
|
+
# first line (e.g. what was being done).
|
|
45
|
+
def report(site, error, &context)
|
|
46
|
+
detail = "#{error.class}: #{error.message}"
|
|
47
|
+
detail = "#{context.call} — #{detail}" if context
|
|
48
|
+
return Cogworker.logger.error { "#{site}: #{detail}" } unless unavailable?(error)
|
|
49
|
+
|
|
50
|
+
line = @mutex.synchronize { track(site, error, detail) }
|
|
51
|
+
Cogworker.logger.error { line } if line
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Call wherever `site` has just done its job: ends an outage there.
|
|
55
|
+
def recovered(site)
|
|
56
|
+
outage = @mutex.synchronize { @outages.delete(site) }
|
|
57
|
+
return unless outage
|
|
58
|
+
|
|
59
|
+
Cogworker.logger.warn do
|
|
60
|
+
"#{site}: Redis available again after #{outage[:count]} error(s) over #{(now - outage[:since]).round}s"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def reset!
|
|
65
|
+
@mutex.synchronize { @outages.clear }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def track(site, error, detail)
|
|
71
|
+
outage = @outages[site]
|
|
72
|
+
unless outage
|
|
73
|
+
@outages[site] = { count: 1, since: now, summarized_at: now, suppressed: 0 }
|
|
74
|
+
return "#{site}: Redis unavailable — #{detail} (repeats summarized every #{SUMMARY_INTERVAL}s)"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
outage[:count] += 1
|
|
78
|
+
outage[:suppressed] += 1
|
|
79
|
+
return unless now - outage[:summarized_at] >= SUMMARY_INTERVAL
|
|
80
|
+
|
|
81
|
+
suppressed = outage[:suppressed]
|
|
82
|
+
outage[:summarized_at] = now
|
|
83
|
+
outage[:suppressed] = 0
|
|
84
|
+
"#{site}: Redis still unavailable — #{suppressed} more error(s) in the last #{SUMMARY_INTERVAL}s " \
|
|
85
|
+
"(latest: #{error.class}: #{error.message})"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def now
|
|
89
|
+
::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
data/lib/cogworker/redis_keys.rb
CHANGED
|
@@ -17,6 +17,19 @@ 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'
|
|
29
|
+
# queue => Redis time a live process last said it reads it (every
|
|
30
|
+
# heartbeat). Unlike QUEUES (the Web UI's listing, never pruned), this
|
|
31
|
+
# tells "nobody reads this queue any more" apart from "somebody does".
|
|
32
|
+
LIVE_QUEUES = 'cogworker:live_queues'
|
|
20
33
|
QUEUES = 'cogworker:queues'
|
|
21
34
|
SCHEDULE = 'cogworker:schedule'
|
|
22
35
|
RETRY = 'cogworker:retry'
|