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
data/lib/cogworker/processor.rb
CHANGED
|
@@ -86,14 +86,22 @@ 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
|
+
disposition = nil
|
|
91
93
|
begin
|
|
92
94
|
disposition = execute(work)
|
|
93
95
|
ensure
|
|
94
96
|
@busy = false
|
|
95
97
|
@manager.processor_idle!
|
|
96
|
-
|
|
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)
|
|
97
105
|
end
|
|
98
106
|
end
|
|
99
107
|
|
|
@@ -123,25 +131,59 @@ module Cogworker
|
|
|
123
131
|
def interrupt(work, job, error)
|
|
124
132
|
payload = JSON.parse(work.raw_job)
|
|
125
133
|
count = payload['interrupted_count'].to_i + 1
|
|
126
|
-
|
|
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,
|
|
127
138
|
'error_class' => error.class.name, 'error_message' => JobUtil.error_message(error),
|
|
128
139
|
'failed_at' => Time.now.to_f)
|
|
129
|
-
|
|
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
|
|
130
145
|
[RedisKeys::DEAD, Time.now.to_f]
|
|
131
146
|
else
|
|
132
|
-
[RedisKeys::RETRY, Time.now.to_f +
|
|
147
|
+
[RedisKeys::RETRY, Time.now.to_f + delay]
|
|
133
148
|
end
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
|
138
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}" }
|
|
139
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)
|
|
140
173
|
Cogworker.logger.error do
|
|
141
174
|
"couldn't record or interrupt jid=#{jid_of(work)} (left in progress): #{e.class}: #{e.message}"
|
|
142
175
|
end
|
|
143
176
|
end
|
|
144
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
|
+
|
|
145
187
|
# A failed ack leaves an already-finished job on this process's own
|
|
146
188
|
# in-progress list, where `Manager#stop!` would later requeue it — a
|
|
147
189
|
# silent second run. Retried briefly, then logged with the jid so that
|
|
@@ -156,9 +198,12 @@ module Cogworker
|
|
|
156
198
|
sleep(0.1 * attempts)
|
|
157
199
|
retry
|
|
158
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)
|
|
159
204
|
Cogworker.logger.error do
|
|
160
205
|
"couldn't acknowledge finished job jid=#{jid_of(work)} after #{attempts} attempts " \
|
|
161
|
-
"(it may run again
|
|
206
|
+
"(will retry; it may run again if this process stops first): #{e.class}: #{e.message}"
|
|
162
207
|
end
|
|
163
208
|
end
|
|
164
209
|
end
|
|
@@ -169,17 +214,23 @@ module Cogworker
|
|
|
169
214
|
'?'
|
|
170
215
|
end
|
|
171
216
|
|
|
172
|
-
# Returns how `settle` should dispose of the fetched job
|
|
173
|
-
#
|
|
174
|
-
#
|
|
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.
|
|
175
220
|
def execute(work)
|
|
176
221
|
job = parse_job(work)
|
|
177
222
|
return :acknowledge unless job
|
|
178
223
|
|
|
179
|
-
|
|
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) }
|
|
180
230
|
Cogworker.logger.info { "start: #{job['class']} jid=#{job['jid']}" }
|
|
181
231
|
begin
|
|
182
232
|
error = run_job(work, job)
|
|
233
|
+
@ran = error ? [:interrupt, job, error] : :acknowledge
|
|
183
234
|
error ? finish_failure(job, error) : finish_success(job)
|
|
184
235
|
ensure
|
|
185
236
|
best_effort('deregister') { deregister_from_workers }
|
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'
|
|
@@ -28,6 +28,7 @@ module Cogworker
|
|
|
28
28
|
# on an idle process — the trade-off against how often idle processors
|
|
29
29
|
# poll Redis (at 0.25s flat, 4 calls/s per thread).
|
|
30
30
|
EMPTY_POLL_INTERVAL = 0.25
|
|
31
|
+
ORPHANINGS_TTL = 7 * 24 * 60 * 60
|
|
31
32
|
MIN_REDIS_VERSION = Gem::Version.new('6.2')
|
|
32
33
|
|
|
33
34
|
# KEYS[1..n-1] = queue keys, in the order to try; KEYS[n] = in-progress list.
|
|
@@ -73,47 +74,147 @@ module Cogworker
|
|
|
73
74
|
# KEYS[3] = LAST_BEAT; ARGV[1..4] = requeue_job's prefix/default
|
|
74
75
|
# queue/lock prefix/lock TTL, ARGV[5] = owner identity, ARGV[6] = orphan
|
|
75
76
|
# threshold, or "" to requeue unconditionally (this process's own list,
|
|
76
|
-
# on shutdown)
|
|
77
|
-
#
|
|
78
|
-
#
|
|
79
|
-
#
|
|
77
|
+
# on shutdown), ARGV[7] = the caller's clock (epoch s) in case TIME is
|
|
78
|
+
# refused; ARGV[8] = orphan-counter key prefix, ARGV[9] = its TTL,
|
|
79
|
+
# ARGV[10] = config.max_orphanings, ARGV[11..] = jobs to drop or keep
|
|
80
|
+
# (see below); KEYS[4] = REPEAT_ORPHANS, KEYS[5] = cogworker:dead.
|
|
81
|
+
# Returns {moved, <jobs filed straight into dead, raw>...}, or {-1} if
|
|
82
|
+
# the owner turned out to be alive.
|
|
83
|
+
# Oldest first, so recovered jobs keep their order. The "is the owner
|
|
84
|
+
# really dead?" check is made here, atomically with the requeue:
|
|
85
|
+
# checked separately, an owner that
|
|
80
86
|
# beat in between would lose jobs it is still running to a second run.
|
|
81
87
|
# Atomic per call, so two processes recovering one list can't both move
|
|
82
88
|
# a job.
|
|
83
89
|
REQUEUE_SCRIPT = REQUEUE_JOB_FUNCTION + <<~LUA
|
|
84
90
|
if ARGV[6] ~= "" then
|
|
85
91
|
if redis.call("EXISTS", KEYS[2]) == 1 then
|
|
86
|
-
return -1
|
|
92
|
+
return {-1}
|
|
87
93
|
end
|
|
88
|
-
|
|
94
|
+
-- A stamp that isn't a number counts as no stamp (the presence key
|
|
95
|
+
-- alone then decides); TIME refused (ACL, proxy) falls back to the
|
|
96
|
+
-- caller's clock (ARGV[7]) rather than failing the whole check.
|
|
97
|
+
local last = tonumber(redis.call("HGET", KEYS[3], ARGV[5]) or "")
|
|
89
98
|
if last then
|
|
90
99
|
if redis.replicate_commands then redis.replicate_commands() end
|
|
91
|
-
local now = tonumber(
|
|
92
|
-
|
|
93
|
-
|
|
100
|
+
local now = tonumber(ARGV[7])
|
|
101
|
+
local ok, time = pcall(redis.call, "TIME")
|
|
102
|
+
if ok then now = tonumber(time[1]) end
|
|
103
|
+
if now - last <= tonumber(ARGV[6]) then
|
|
104
|
+
return {-1}
|
|
94
105
|
end
|
|
95
106
|
end
|
|
96
107
|
end
|
|
108
|
+
-- ARGV[11..] = jobs (as on the list) to leave alone instead of
|
|
109
|
+
-- requeueing: "d:<job>" ones are dropped (they finished, only their
|
|
110
|
+
-- ack failed — dropping them is the ack), "k:<job>" ones are kept on
|
|
111
|
+
-- the list (they ran; see Manager#requeue_unfinished).
|
|
112
|
+
local finished, keep = {}, {}
|
|
113
|
+
for i = 11, #ARGV do
|
|
114
|
+
local mark, payload = string.sub(ARGV[i], 1, 2), string.sub(ARGV[i], 3)
|
|
115
|
+
if mark == "d:" then finished[payload] = true else keep[payload] = true end
|
|
116
|
+
end
|
|
97
117
|
local moved = 0
|
|
98
|
-
|
|
99
|
-
|
|
118
|
+
local kept = {}
|
|
119
|
+
local buried = {} -- filed straight into dead, raw: the caller completes them
|
|
120
|
+
-- One pass over the list as it stands. Each job is peeked, written
|
|
121
|
+
-- where it goes, and only then taken off: a script stopped by an
|
|
122
|
+
-- error keeps the writes it made before it (Redis doesn't roll them
|
|
123
|
+
-- back), so popping first lost the job whenever the write after it
|
|
124
|
+
-- failed. A job whose requeue fails (its queue key of the wrong type)
|
|
125
|
+
-- goes to the back of the list instead, so it can't hold up the ones
|
|
126
|
+
-- behind it — it's retried on the next pass.
|
|
127
|
+
for _ = 1, redis.call("LLEN", KEYS[1]) do
|
|
128
|
+
local job = redis.call("LINDEX", KEYS[1], 0)
|
|
100
129
|
if not job then
|
|
101
|
-
|
|
130
|
+
break
|
|
102
131
|
end
|
|
103
|
-
|
|
104
|
-
|
|
132
|
+
if finished[job] then
|
|
133
|
+
redis.call("LPOP", KEYS[1])
|
|
134
|
+
local ok, decoded = pcall(cjson.decode, job)
|
|
135
|
+
if ok and type(decoded) == "table" and type(decoded["jid"]) == "string" then
|
|
136
|
+
redis.pcall("DEL", ARGV[8] .. decoded["jid"]) -- it finished: its orphan count is moot
|
|
137
|
+
end
|
|
138
|
+
elseif keep[job] then
|
|
139
|
+
table.insert(kept, redis.call("LPOP", KEYS[1]))
|
|
140
|
+
else
|
|
141
|
+
-- Only when recovering from a dead process (not a shutdown's own
|
|
142
|
+
-- requeue): count how often this jid was orphaned, and park it
|
|
143
|
+
-- for `dead` past the limit — a job that itself kills its process
|
|
144
|
+
-- (OOM, SIGKILL) would otherwise come back after every crash,
|
|
145
|
+
-- forever. The counter is best-effort, and only bumped once the
|
|
146
|
+
-- job has been written.
|
|
147
|
+
local counter = nil
|
|
148
|
+
local parked = false
|
|
149
|
+
if ARGV[6] ~= "" then
|
|
150
|
+
local ok, decoded = pcall(cjson.decode, job)
|
|
151
|
+
if ok and type(decoded) == "table" and type(decoded["jid"]) == "string" then
|
|
152
|
+
counter = ARGV[8] .. decoded["jid"]
|
|
153
|
+
local current = redis.pcall("GET", counter)
|
|
154
|
+
local count = type(current) == "string" and tonumber(current) or nil
|
|
155
|
+
if count == nil and current then
|
|
156
|
+
-- Ours, but unusable (not a number, or not even a string):
|
|
157
|
+
-- start over, rather than let it switch the limit off.
|
|
158
|
+
redis.pcall("DEL", counter)
|
|
159
|
+
end
|
|
160
|
+
count = (count or 0) + 1
|
|
161
|
+
if count > tonumber(ARGV[10]) then
|
|
162
|
+
parked = type(redis.pcall("RPUSH", KEYS[4], job)) == "number"
|
|
163
|
+
-- Parking list unusable: straight to dead (without the
|
|
164
|
+
-- explanatory error a parked one gets), rather than let the
|
|
165
|
+
-- limit be bypassed.
|
|
166
|
+
if not parked then
|
|
167
|
+
parked = type(redis.pcall("ZADD", KEYS[5], ARGV[7], job)) == "number"
|
|
168
|
+
if parked then table.insert(buried, job) end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
local written = parked or pcall(requeue_job, job, ARGV[1], ARGV[2], ARGV[3], ARGV[4])
|
|
174
|
+
redis.call("LPOP", KEYS[1])
|
|
175
|
+
if written then
|
|
176
|
+
if counter then
|
|
177
|
+
redis.pcall("INCR", counter)
|
|
178
|
+
redis.pcall("EXPIRE", counter, ARGV[9])
|
|
179
|
+
end
|
|
180
|
+
moved = moved + 1
|
|
181
|
+
else
|
|
182
|
+
redis.call("RPUSH", KEYS[1], job)
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
for _, job in ipairs(kept) do
|
|
187
|
+
redis.call("RPUSH", KEYS[1], job)
|
|
105
188
|
end
|
|
189
|
+
local reply = {moved}
|
|
190
|
+
for _, job in ipairs(buried) do table.insert(reply, job) end
|
|
191
|
+
return reply
|
|
192
|
+
LUA
|
|
193
|
+
|
|
194
|
+
# KEYS[1] = REPEAT_ORPHANS, KEYS[2] = cogworker:dead; ARGV[1] = parked
|
|
195
|
+
# job, ARGV[2] = score, ARGV[3] = its dead entry. Moved in one step.
|
|
196
|
+
# (Head of the list only — the caller walks it in order — so no LPOS,
|
|
197
|
+
# which Redis < 6.0.6 lacks: this runs in `:basic` mode too.)
|
|
198
|
+
BURY_ORPHAN_SCRIPT = <<~LUA
|
|
199
|
+
if redis.call("LINDEX", KEYS[1], 0) ~= ARGV[1] then
|
|
200
|
+
return 0
|
|
201
|
+
end
|
|
202
|
+
redis.call("ZADD", KEYS[2], ARGV[2], ARGV[3])
|
|
203
|
+
redis.call("LPOP", KEYS[1])
|
|
204
|
+
return 1
|
|
106
205
|
LUA
|
|
107
206
|
|
|
108
207
|
# KEYS[1] = an in-progress list; ARGV[1] = one job on it, ARGV[2..5] =
|
|
109
208
|
# requeue_job's arguments. That one job, if still on the list, back on
|
|
110
209
|
# its queue: `give_back`, and `reconcile`'s strays.
|
|
210
|
+
# Present? Write, then remove — see REQUEUE_SCRIPT for why that order.
|
|
111
211
|
REQUEUE_ONE_SCRIPT = REQUEUE_JOB_FUNCTION + <<~LUA
|
|
112
|
-
if redis.call("
|
|
113
|
-
|
|
114
|
-
return 1
|
|
212
|
+
if not redis.call("LPOS", KEYS[1], ARGV[1]) then
|
|
213
|
+
return 0
|
|
115
214
|
end
|
|
116
|
-
|
|
215
|
+
requeue_job(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5])
|
|
216
|
+
redis.call("LREM", KEYS[1], 1, ARGV[1])
|
|
217
|
+
return 1
|
|
117
218
|
LUA
|
|
118
219
|
|
|
119
220
|
# KEYS[1] = in-progress list, KEYS[2] = retry/dead ZSET; ARGV[1] = the
|
|
@@ -121,11 +222,42 @@ module Cogworker
|
|
|
121
222
|
# in-progress list and into the ZSET in one step; a no-op if it's no
|
|
122
223
|
# longer in progress (already requeued by a shutdown).
|
|
123
224
|
INTERRUPT_SCRIPT = <<~LUA
|
|
124
|
-
if redis.call("
|
|
125
|
-
|
|
126
|
-
|
|
225
|
+
if not redis.call("LPOS", KEYS[1], ARGV[1]) then
|
|
226
|
+
return 0
|
|
227
|
+
end
|
|
228
|
+
redis.call("ZADD", KEYS[2], ARGV[2], ARGV[3])
|
|
229
|
+
redis.call("LREM", KEYS[1], 1, ARGV[1])
|
|
230
|
+
return 1
|
|
231
|
+
LUA
|
|
232
|
+
|
|
233
|
+
# KEYS[1] = in-progress list, KEYS[2] = UNSETTLED; ARGV[1] = the job as
|
|
234
|
+
# fetched, ARGV[2] = its {set, score, payload} record. Present? Hand
|
|
235
|
+
# over, then remove.
|
|
236
|
+
PARK_UNSETTLED_SCRIPT = <<~LUA
|
|
237
|
+
if not redis.call("LPOS", KEYS[1], ARGV[1]) then
|
|
238
|
+
return 0
|
|
239
|
+
end
|
|
240
|
+
redis.call("RPUSH", KEYS[2], ARGV[2])
|
|
241
|
+
redis.call("LREM", KEYS[1], 1, ARGV[1])
|
|
242
|
+
return 1
|
|
243
|
+
LUA
|
|
244
|
+
|
|
245
|
+
# KEYS[1] = UNSETTLED; ARGV[1] = one record (its head), ARGV[2..4] =
|
|
246
|
+
# set, score, payload. Filed, then removed.
|
|
247
|
+
FILE_UNSETTLED_SCRIPT = <<~LUA
|
|
248
|
+
if redis.call("LINDEX", KEYS[1], 0) ~= ARGV[1] then
|
|
249
|
+
return 0
|
|
127
250
|
end
|
|
128
|
-
|
|
251
|
+
redis.call("ZADD", ARGV[2], ARGV[3], ARGV[4])
|
|
252
|
+
redis.call("LPOP", KEYS[1])
|
|
253
|
+
return 1
|
|
254
|
+
LUA
|
|
255
|
+
|
|
256
|
+
# KEYS[1] = in-progress list, KEYS[2] = the job's orphan counter.
|
|
257
|
+
ACK_SCRIPT = <<~LUA
|
|
258
|
+
redis.call("LREM", KEYS[1], 1, ARGV[1])
|
|
259
|
+
redis.pcall("DEL", KEYS[2])
|
|
260
|
+
return 1
|
|
129
261
|
LUA
|
|
130
262
|
|
|
131
263
|
class << self
|
|
@@ -148,12 +280,21 @@ module Cogworker
|
|
|
148
280
|
# owner turns out not to be dead (see REQUEUE_SCRIPT). Used without
|
|
149
281
|
# it for this process's own unfinished jobs on shutdown
|
|
150
282
|
# (`Manager#stop!`), with it for presumed-dead processes' lists.
|
|
151
|
-
|
|
152
|
-
|
|
283
|
+
#
|
|
284
|
+
# `except:` — jobs on the list that already finished but whose ack
|
|
285
|
+
# failed: dropped from it (which is their ack) rather than requeued,
|
|
286
|
+
# in the same atomic step. `keep:` — jobs left on the list untouched.
|
|
287
|
+
def requeue_in_progress(identity, if_dead_for: nil, except: [], keep: [])
|
|
288
|
+
moved, *buried = Cogworker.config.redis do |c|
|
|
153
289
|
LuaScript.run(c, REQUEUE_SCRIPT,
|
|
154
|
-
|
|
155
|
-
|
|
290
|
+
keys: [RedisKeys.in_progress(identity), RedisKeys.process(identity), RedisKeys::LAST_BEAT,
|
|
291
|
+
RedisKeys::REPEAT_ORPHANS, RedisKeys::DEAD],
|
|
292
|
+
argv: [*requeue_args, identity, if_dead_for.to_s, Time.now.to_i,
|
|
293
|
+
RedisKeys::ORPHANINGS_PREFIX, ORPHANINGS_TTL, Cogworker.config.max_orphanings,
|
|
294
|
+
*except.map { |raw| "d:#{raw}" }, *keep.map { |raw| "k:#{raw}" }])
|
|
156
295
|
end
|
|
296
|
+
complete_raw_burials(buried)
|
|
297
|
+
moved
|
|
157
298
|
end
|
|
158
299
|
|
|
159
300
|
def requeue_args
|
|
@@ -174,40 +315,180 @@ module Cogworker
|
|
|
174
315
|
# reliable-fetch process registers itself there on every beat) rather
|
|
175
316
|
# than SCANning the keyspace; `scan: true` (once per reliable process,
|
|
176
317
|
# at boot) also picks up lists from before that set existed.
|
|
177
|
-
|
|
318
|
+
#
|
|
319
|
+
# One identity that can't be processed (its list of the wrong type, a
|
|
320
|
+
# corrupt stamp, ...) is logged and skipped — it must never stop the
|
|
321
|
+
# others, let alone the poller or the cron ticker waiting on it.
|
|
322
|
+
#
|
|
323
|
+
# `report:` (a Hash, if given) gets `:registry` / `:scan` set to
|
|
324
|
+
# whether reading that source of candidates worked — what tells the
|
|
325
|
+
# caller a pass really looked, as opposed to one that just survived.
|
|
326
|
+
def recover_orphans(scan: false, report: {})
|
|
178
327
|
own = Cogworker.identity
|
|
179
|
-
candidate_identities(scan: scan).sum do |identity|
|
|
328
|
+
moved = candidate_identities(scan: scan, report: report).sum do |identity|
|
|
180
329
|
next 0 if identity == own
|
|
181
330
|
|
|
182
|
-
|
|
183
|
-
|
|
331
|
+
recover_identity(identity)
|
|
332
|
+
rescue StandardError => e
|
|
333
|
+
report[:connection_lost] = true if e.is_a?(Redis::BaseConnectionError)
|
|
334
|
+
Cogworker.logger.error { "orphan recovery skipped #{identity}: #{e.class}: #{e.message}" }
|
|
335
|
+
0
|
|
336
|
+
end
|
|
337
|
+
isolated('burying repeat orphans', nil) { bury_repeat_orphans }
|
|
338
|
+
isolated('filing unsettled jobs', nil) { file_unsettled }
|
|
339
|
+
moved
|
|
340
|
+
end
|
|
184
341
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
342
|
+
# Files every job REQUEUE_SCRIPT parked (orphaned more than
|
|
343
|
+
# `config.max_orphanings` times) in `dead`, with an error saying why — whatever
|
|
344
|
+
# its `retry` setting, since each of those runs ended with its process
|
|
345
|
+
# gone rather than an error to retry on. Parked entries survive a
|
|
346
|
+
# failure here and are picked up on the next pass.
|
|
347
|
+
def bury_repeat_orphans
|
|
348
|
+
Cogworker.config.redis do |c|
|
|
349
|
+
c.lrange(RedisKeys::REPEAT_ORPHANS, 0, -1).each do |raw|
|
|
350
|
+
job = parse_hash(raw) || JobUtil.unparseable_job(raw, queue: nil, error: JSON::ParserError.new('unreadable'))
|
|
351
|
+
job.merge!(repeat_orphan_error)
|
|
352
|
+
buried = LuaScript.run(c, BURY_ORPHAN_SCRIPT, keys: [RedisKeys::REPEAT_ORPHANS, RedisKeys::DEAD],
|
|
353
|
+
argv: [raw, Time.now.to_f, JSON.generate(job)])
|
|
354
|
+
next unless buried == 1
|
|
355
|
+
|
|
356
|
+
Cogworker.logger.error { "jid=#{job['jid']} orphaned too often, moved to dead" }
|
|
357
|
+
buried_as_dead(c, job)
|
|
358
|
+
end
|
|
188
359
|
end
|
|
189
360
|
end
|
|
190
361
|
|
|
362
|
+
def recover_identity(identity)
|
|
363
|
+
moved = requeue_in_progress(identity, if_dead_for: Cogworker.config.orphan_threshold)
|
|
364
|
+
return 0 if moved.negative? # alive after all: keep its registration and last beat
|
|
365
|
+
|
|
366
|
+
isolated("forgetting #{identity}", nil) { forget_if_empty(identity) } # its jobs are already back
|
|
367
|
+
Cogworker.logger.warn { "requeued #{moved} job(s) left in progress by dead process #{identity}" } if moved.positive?
|
|
368
|
+
moved
|
|
369
|
+
end
|
|
370
|
+
|
|
191
371
|
# This process's own in-progress entries that no processor thread is
|
|
192
|
-
# running
|
|
193
|
-
#
|
|
194
|
-
#
|
|
195
|
-
#
|
|
196
|
-
# in
|
|
197
|
-
#
|
|
198
|
-
|
|
372
|
+
# running — judged by the process's own in-memory record (`running:`,
|
|
373
|
+
# from the Manager), never by `cogworker:workers` in Redis, which a
|
|
374
|
+
# failover can lose while a long job is still running. An entry whose
|
|
375
|
+
# job finished but isn't settled yet (`pending:` — its ack failed, or
|
|
376
|
+
# filing its failure in retry/dead did) gets that finished here and is
|
|
377
|
+
# yielded (so the caller can forget it) — never re-run. Any other
|
|
378
|
+
# stray is requeued once seen on two calls in a row (the caller passes
|
|
379
|
+
# back what the previous call returned): the second look rules out a
|
|
380
|
+
# job caught between being fetched and being recorded as running.
|
|
381
|
+
# Returns this call's strays not requeued yet.
|
|
382
|
+
def reconcile(identity, previous_strays = [], running: [], pending: {}, &settled)
|
|
383
|
+
key = RedisKeys.in_progress(identity)
|
|
384
|
+
listed = Cogworker.config.redis { |c| c.lrange(key, 0, -1) }
|
|
385
|
+
strays = listed - running
|
|
386
|
+
finished, strays = strays.partition { |raw| pending.key?(raw) }
|
|
387
|
+
settle_pending(identity, pending.slice(*finished), &settled)
|
|
199
388
|
Cogworker.config.redis do |c|
|
|
200
|
-
listed = c.lrange(RedisKeys.in_progress(identity), 0, -1)
|
|
201
|
-
running = c.hvals(RedisKeys.workers(identity)).filter_map { |raw| parse_hash(raw)&.dig('payload', 'jid') }
|
|
202
|
-
strays = listed.reject { |raw| running.include?(parse_hash(raw)&.fetch('jid', nil)) }
|
|
203
389
|
(strays & previous_strays).each do |raw|
|
|
204
|
-
next unless LuaScript.run(c, REQUEUE_ONE_SCRIPT, keys: [
|
|
205
|
-
argv: [raw, *requeue_args]) == 1
|
|
390
|
+
next unless LuaScript.run(c, REQUEUE_ONE_SCRIPT, keys: [key], argv: [raw, *requeue_args]) == 1
|
|
206
391
|
|
|
207
392
|
Cogworker.logger.warn { "requeued stray in-progress job jid=#{parse_hash(raw)&.fetch('jid', nil)}" }
|
|
208
393
|
end
|
|
209
|
-
strays - previous_strays
|
|
210
394
|
end
|
|
395
|
+
strays - previous_strays
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# Finishes each pending settlement (`raw => :ack | [set, score,
|
|
399
|
+
# payload]`) that can be finished now, yielding each one done. One
|
|
400
|
+
# that fails is logged and stays pending. (Filing one doesn't repeat
|
|
401
|
+
# the rest of the failure path, and needn't: `stats:failed` was
|
|
402
|
+
# counted when the job failed, and a terminal failure's unique lock
|
|
403
|
+
# already released by UniqueJobs::ReleaseMiddleware in the chain. Nor
|
|
404
|
+
# are locks extended over an interrupt's delay — at most
|
|
405
|
+
# Processor::INTERRUPT_DELAY * MAX_INTERRUPTS, well inside
|
|
406
|
+
# RunningLock.active_ttl.)
|
|
407
|
+
def settle_pending(identity, pending)
|
|
408
|
+
key = RedisKeys.in_progress(identity)
|
|
409
|
+
pending.each do |raw, how|
|
|
410
|
+
Cogworker.config.redis do |c|
|
|
411
|
+
if how == :ack
|
|
412
|
+
jid = parse_hash(raw)&.fetch('jid', nil)
|
|
413
|
+
LuaScript.run(c, ACK_SCRIPT, keys: [key, RedisKeys::ORPHANINGS_PREFIX + jid.to_s], argv: [raw])
|
|
414
|
+
else
|
|
415
|
+
set, score, payload = how
|
|
416
|
+
LuaScript.run(c, INTERRUPT_SCRIPT, keys: [key, set], argv: [raw, score, payload])
|
|
417
|
+
end
|
|
418
|
+
end
|
|
419
|
+
yield raw if block_given?
|
|
420
|
+
rescue StandardError => e
|
|
421
|
+
Cogworker.logger.error { "still couldn't settle jid=#{parse_hash(raw)&.fetch('jid', nil)}: #{e.class}: #{e.message}" }
|
|
422
|
+
end
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
# On shutdown (`Manager#requeue_unfinished`): a job that ran and
|
|
426
|
+
# failed but couldn't be filed in retry/dead even now is handed over
|
|
427
|
+
# to UNSETTLED with its record, so any live process can file it later
|
|
428
|
+
# (`file_unsettled`) — rather than requeued with the rest, which ran a
|
|
429
|
+
# `retry: false` job again on every deploy. Returns the jobs that
|
|
430
|
+
# couldn't even be handed over.
|
|
431
|
+
def park_unsettled(identity, pending)
|
|
432
|
+
pending.reject do |raw, (set, score, payload)|
|
|
433
|
+
record = JSON.generate('set' => set, 'score' => score, 'payload' => payload)
|
|
434
|
+
Cogworker.config.redis do |c|
|
|
435
|
+
LuaScript.run(c, PARK_UNSETTLED_SCRIPT, keys: [RedisKeys.in_progress(identity), RedisKeys::UNSETTLED],
|
|
436
|
+
argv: [raw, record])
|
|
437
|
+
end
|
|
438
|
+
true
|
|
439
|
+
rescue StandardError => e
|
|
440
|
+
Cogworker.logger.error { "couldn't hand over unsettled jid=#{parse_hash(raw)&.fetch('jid', nil)}: #{e.class}: #{e.message}" }
|
|
441
|
+
false
|
|
442
|
+
end.keys
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
# Files what shutting-down processes handed over in UNSETTLED, head
|
|
446
|
+
# first, stopping at the first that still can't be filed (retried on
|
|
447
|
+
# the next pass).
|
|
448
|
+
def file_unsettled
|
|
449
|
+
Cogworker.config.redis do |c|
|
|
450
|
+
c.lrange(RedisKeys::UNSETTLED, 0, -1).each do |record|
|
|
451
|
+
entry = parse_hash(record)
|
|
452
|
+
next c.lrem(RedisKeys::UNSETTLED, 1, record) unless entry # unreadable: nothing to file
|
|
453
|
+
|
|
454
|
+
LuaScript.run(c, FILE_UNSETTLED_SCRIPT, keys: [RedisKeys::UNSETTLED],
|
|
455
|
+
argv: [record, entry['set'], entry['score'], entry['payload']])
|
|
456
|
+
end
|
|
457
|
+
end
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
# Jobs REQUEUE_SCRIPT had to file in dead raw (its parking list
|
|
461
|
+
# unusable): swapped for a proper dead entry, and given the usual
|
|
462
|
+
# terminal-failure side effects. Best-effort, per job.
|
|
463
|
+
def complete_raw_burials(raws)
|
|
464
|
+
raws.each do |raw|
|
|
465
|
+
job = (parse_hash(raw) || {}).merge(repeat_orphan_error)
|
|
466
|
+
Cogworker.config.redis do |c|
|
|
467
|
+
c.zadd(RedisKeys::DEAD, Time.now.to_f, JSON.generate(job))
|
|
468
|
+
c.zrem(RedisKeys::DEAD, raw)
|
|
469
|
+
buried_as_dead(c, job)
|
|
470
|
+
end
|
|
471
|
+
rescue StandardError => e
|
|
472
|
+
Cogworker.logger.error { "completing the dead entry of a repeat orphan failed: #{e.class}: #{e.message}" }
|
|
473
|
+
end
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
def repeat_orphan_error
|
|
477
|
+
{ 'error_class' => 'Cogworker::ProcessDied', 'failed_at' => Time.now.to_f,
|
|
478
|
+
'error_message' => "its process died while running it more than #{Cogworker.config.max_orphanings} times " \
|
|
479
|
+
'(killed for memory? SIGKILL?) — not retried again' }
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
# What any other terminal failure does on its way to dead: counted as
|
|
483
|
+
# failed, and its `until_executed` locks released (so the entry / the
|
|
484
|
+
# unique key isn't blocked until the locks' TTL). Best-effort.
|
|
485
|
+
def buried_as_dead(conn, job)
|
|
486
|
+
conn.incr(RedisKeys::STATS_FAILED)
|
|
487
|
+
OwnedKey.delete(RedisKeys.unique_lock(UniqueJobs.digest(job)), job['jid'], conn) if UniqueJobs.until_executed?(job)
|
|
488
|
+
Periodic::RunningLock.release(job['periodic_pjid'], job['jid'], conn) if job['periodic_pjid']
|
|
489
|
+
conn.del(RedisKeys::ORPHANINGS_PREFIX + job['jid'].to_s)
|
|
490
|
+
rescue StandardError => e
|
|
491
|
+
Cogworker.logger.error { "after burying jid=#{job['jid']}: #{e.class}: #{e.message}" }
|
|
211
492
|
end
|
|
212
493
|
|
|
213
494
|
def parse_hash(raw)
|
|
@@ -217,16 +498,30 @@ module Cogworker
|
|
|
217
498
|
nil
|
|
218
499
|
end
|
|
219
500
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
501
|
+
# Each source on its own: the registry set unreadable (wrong type)
|
|
502
|
+
# mustn't also lose the one-off scan, and vice versa.
|
|
503
|
+
def candidate_identities(scan:, report: {})
|
|
504
|
+
registered = isolated('reading the in-progress registry', nil, report) do
|
|
505
|
+
Cogworker.config.redis { |c| c.smembers(RedisKeys::IN_PROGRESS_IDENTITIES) }
|
|
506
|
+
end
|
|
507
|
+
report[:registry] = !registered.nil?
|
|
508
|
+
return registered.to_a unless scan
|
|
224
509
|
|
|
225
|
-
|
|
226
|
-
|
|
510
|
+
scanned = isolated('scanning for in-progress lists', nil, report) do
|
|
511
|
+
Cogworker.config.redis do |c|
|
|
512
|
+
c.scan_each(match: "#{RedisKeys::IN_PROGRESS_PREFIX}*").map { |key| key.delete_prefix(RedisKeys::IN_PROGRESS_PREFIX) }
|
|
227
513
|
end
|
|
228
|
-
identities | scanned
|
|
229
514
|
end
|
|
515
|
+
report[:scan] = !scanned.nil?
|
|
516
|
+
registered.to_a | scanned.to_a
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
def isolated(what, fallback, report = nil)
|
|
520
|
+
yield
|
|
521
|
+
rescue StandardError => e
|
|
522
|
+
report[:connection_lost] = true if report && e.is_a?(Redis::BaseConnectionError)
|
|
523
|
+
Cogworker.logger.error { "orphan recovery: #{what} failed: #{e.class}: #{e.message}" }
|
|
524
|
+
fallback
|
|
230
525
|
end
|
|
231
526
|
|
|
232
527
|
def forget_if_empty(identity)
|
|
@@ -277,8 +572,15 @@ module Cogworker
|
|
|
277
572
|
end
|
|
278
573
|
end
|
|
279
574
|
|
|
575
|
+
# Also clears the job's orphan counter: it got to finish, so whatever
|
|
576
|
+
# crashes it sat through before weren't its doing (or are behind it).
|
|
280
577
|
def acknowledge(work)
|
|
281
|
-
|
|
578
|
+
jid = self.class.parse_hash(work.raw_job)&.fetch('jid', nil)
|
|
579
|
+
Cogworker.config.redis do |c|
|
|
580
|
+
LuaScript.run(c, ACK_SCRIPT, keys: [RedisKeys.in_progress(Cogworker.identity),
|
|
581
|
+
RedisKeys::ORPHANINGS_PREFIX + jid.to_s],
|
|
582
|
+
argv: [work.raw_job])
|
|
583
|
+
end
|
|
282
584
|
end
|
|
283
585
|
|
|
284
586
|
private
|