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
|
@@ -28,6 +28,10 @@ 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
|
+
# A queue counts as read if a live process stamped it (every heartbeat)
|
|
32
|
+
# this recently.
|
|
33
|
+
LIVE_QUEUE_WINDOW = 2 * Heartbeat::TTL
|
|
34
|
+
ORPHANINGS_TTL = 7 * 24 * 60 * 60
|
|
31
35
|
MIN_REDIS_VERSION = Gem::Version.new('6.2')
|
|
32
36
|
|
|
33
37
|
# KEYS[1..n-1] = queue keys, in the order to try; KEYS[n] = in-progress list.
|
|
@@ -66,6 +70,7 @@ module Cogworker
|
|
|
66
70
|
end
|
|
67
71
|
end
|
|
68
72
|
redis.call("RPUSH", queue_prefix .. queue, job)
|
|
73
|
+
return queue
|
|
69
74
|
end
|
|
70
75
|
LUA
|
|
71
76
|
|
|
@@ -73,47 +78,171 @@ module Cogworker
|
|
|
73
78
|
# KEYS[3] = LAST_BEAT; ARGV[1..4] = requeue_job's prefix/default
|
|
74
79
|
# queue/lock prefix/lock TTL, ARGV[5] = owner identity, ARGV[6] = orphan
|
|
75
80
|
# threshold, or "" to requeue unconditionally (this process's own list,
|
|
76
|
-
# on shutdown)
|
|
77
|
-
#
|
|
78
|
-
#
|
|
79
|
-
#
|
|
81
|
+
# on shutdown), ARGV[7] = the caller's clock (epoch s) in case TIME is
|
|
82
|
+
# refused; ARGV[8] = orphan-counter key prefix, ARGV[9] = its TTL,
|
|
83
|
+
# ARGV[10] = config.max_orphanings, ARGV[11..] = jobs to drop or keep
|
|
84
|
+
# (see below); KEYS[4] = REPEAT_ORPHANS, KEYS[5] = cogworker:dead.
|
|
85
|
+
# Returns {moved, failed, n, <n unlisted queue names>..., <jobs filed
|
|
86
|
+
# straight into dead, raw>...}, or {-1, 0, 0} if the owner turned out to
|
|
87
|
+
# be alive. KEYS[6] = cogworker:queues, KEYS[7] = LIVE_QUEUES.
|
|
88
|
+
# Oldest first, so recovered jobs keep their order. The "is the owner
|
|
89
|
+
# really dead?" check is made here, atomically with the requeue:
|
|
90
|
+
# checked separately, an owner that
|
|
80
91
|
# beat in between would lose jobs it is still running to a second run.
|
|
81
92
|
# Atomic per call, so two processes recovering one list can't both move
|
|
82
93
|
# a job.
|
|
83
94
|
REQUEUE_SCRIPT = REQUEUE_JOB_FUNCTION + <<~LUA
|
|
84
95
|
if ARGV[6] ~= "" then
|
|
85
96
|
if redis.call("EXISTS", KEYS[2]) == 1 then
|
|
86
|
-
return -1
|
|
97
|
+
return {-1, 0, 0}
|
|
87
98
|
end
|
|
88
|
-
|
|
99
|
+
-- A stamp that isn't a number counts as no stamp (the presence key
|
|
100
|
+
-- alone then decides); TIME refused (ACL, proxy) falls back to the
|
|
101
|
+
-- caller's clock (ARGV[7]) rather than failing the whole check.
|
|
102
|
+
local last = tonumber(redis.call("HGET", KEYS[3], ARGV[5]) or "")
|
|
89
103
|
if last then
|
|
90
104
|
if redis.replicate_commands then redis.replicate_commands() end
|
|
91
|
-
local now = tonumber(
|
|
92
|
-
|
|
93
|
-
|
|
105
|
+
local now = tonumber(ARGV[7])
|
|
106
|
+
local ok, time = pcall(redis.call, "TIME")
|
|
107
|
+
if ok then now = tonumber(time[1]) end
|
|
108
|
+
if now - last <= tonumber(ARGV[6]) then
|
|
109
|
+
return {-1, 0, 0}
|
|
94
110
|
end
|
|
95
111
|
end
|
|
96
112
|
end
|
|
113
|
+
-- ARGV[11..] = jobs (as on the list) to leave alone instead of
|
|
114
|
+
-- requeueing: "d:<job>" ones are dropped (they finished, only their
|
|
115
|
+
-- ack failed — dropping them is the ack), "k:<job>" ones are kept on
|
|
116
|
+
-- the list (they ran; see Manager#requeue_unfinished).
|
|
117
|
+
local finished, keep = {}, {}
|
|
118
|
+
for i = 11, #ARGV do
|
|
119
|
+
local mark, payload = string.sub(ARGV[i], 1, 2), string.sub(ARGV[i], 3)
|
|
120
|
+
if mark == "d:" then finished[payload] = true else keep[payload] = true end
|
|
121
|
+
end
|
|
122
|
+
if redis.replicate_commands then redis.replicate_commands() end
|
|
123
|
+
local clock = tonumber(ARGV[7])
|
|
124
|
+
local clock_ok, clock_reply = pcall(redis.call, "TIME")
|
|
125
|
+
if clock_ok then clock = tonumber(clock_reply[1]) end
|
|
97
126
|
local moved = 0
|
|
98
|
-
|
|
99
|
-
|
|
127
|
+
local kept = {}
|
|
128
|
+
local buried = {} -- filed straight into dead, raw: the caller completes them
|
|
129
|
+
local failed = 0 -- couldn't be requeued (kept, at the back): the caller logs them
|
|
130
|
+
local unlisted = {} -- queues requeued onto that no live process reads
|
|
131
|
+
-- One pass over the list as it stands. Each job is peeked, written
|
|
132
|
+
-- where it goes, and only then taken off: a script stopped by an
|
|
133
|
+
-- error keeps the writes it made before it (Redis doesn't roll them
|
|
134
|
+
-- back), so popping first lost the job whenever the write after it
|
|
135
|
+
-- failed. A job whose requeue fails (its queue key of the wrong type)
|
|
136
|
+
-- goes to the back of the list instead, so it can't hold up the ones
|
|
137
|
+
-- behind it — it's retried on the next pass.
|
|
138
|
+
for _ = 1, redis.call("LLEN", KEYS[1]) do
|
|
139
|
+
local job = redis.call("LINDEX", KEYS[1], 0)
|
|
100
140
|
if not job then
|
|
101
|
-
|
|
141
|
+
break
|
|
142
|
+
end
|
|
143
|
+
if finished[job] then
|
|
144
|
+
redis.call("LPOP", KEYS[1])
|
|
145
|
+
local ok, decoded = pcall(cjson.decode, job)
|
|
146
|
+
if ok and type(decoded) == "table" and type(decoded["jid"]) == "string" then
|
|
147
|
+
redis.pcall("DEL", ARGV[8] .. decoded["jid"]) -- it finished: its orphan count is moot
|
|
148
|
+
end
|
|
149
|
+
elseif keep[job] then
|
|
150
|
+
table.insert(kept, redis.call("LPOP", KEYS[1]))
|
|
151
|
+
else
|
|
152
|
+
-- Only when recovering from a dead process (not a shutdown's own
|
|
153
|
+
-- requeue): count how often this jid was orphaned, and park it
|
|
154
|
+
-- for `dead` past the limit — a job that itself kills its process
|
|
155
|
+
-- (OOM, SIGKILL) would otherwise come back after every crash,
|
|
156
|
+
-- forever. The counter is best-effort, and only bumped once the
|
|
157
|
+
-- job has been written.
|
|
158
|
+
local counter = nil
|
|
159
|
+
local parked = false
|
|
160
|
+
if ARGV[6] ~= "" then
|
|
161
|
+
local ok, decoded = pcall(cjson.decode, job)
|
|
162
|
+
if ok and type(decoded) == "table" and type(decoded["jid"]) == "string" then
|
|
163
|
+
counter = ARGV[8] .. decoded["jid"]
|
|
164
|
+
local current = redis.pcall("GET", counter)
|
|
165
|
+
local count = type(current) == "string" and tonumber(current) or nil
|
|
166
|
+
if count == nil and current then
|
|
167
|
+
-- Ours, but unusable (not a number, or not even a string):
|
|
168
|
+
-- start over, rather than let it switch the limit off.
|
|
169
|
+
redis.pcall("DEL", counter)
|
|
170
|
+
end
|
|
171
|
+
count = (count or 0) + 1
|
|
172
|
+
if count > tonumber(ARGV[10]) then
|
|
173
|
+
parked = type(redis.pcall("RPUSH", KEYS[4], job)) == "number"
|
|
174
|
+
-- Parking list unusable: straight to dead (without the
|
|
175
|
+
-- explanatory error a parked one gets), rather than let the
|
|
176
|
+
-- limit be bypassed.
|
|
177
|
+
if not parked then
|
|
178
|
+
parked = type(redis.pcall("ZADD", KEYS[5], ARGV[7], job)) == "number"
|
|
179
|
+
if parked then table.insert(buried, job) end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
local written, queue = parked, nil
|
|
185
|
+
if not parked then
|
|
186
|
+
written, queue = pcall(requeue_job, job, ARGV[1], ARGV[2], ARGV[3], ARGV[4])
|
|
187
|
+
end
|
|
188
|
+
-- A queue no live process has said it reads lately (KEYS[7]): tell
|
|
189
|
+
-- the caller, since nothing may be reading it — and list it
|
|
190
|
+
-- (KEYS[6]) so the Web UI shows it.
|
|
191
|
+
if written and queue then
|
|
192
|
+
local seen = tonumber(redis.pcall("ZSCORE", KEYS[7], queue) or "")
|
|
193
|
+
if not seen or seen < clock - #{LIVE_QUEUE_WINDOW} then
|
|
194
|
+
redis.pcall("SADD", KEYS[6], queue)
|
|
195
|
+
unlisted[queue] = true
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
redis.call("LPOP", KEYS[1])
|
|
199
|
+
if written then
|
|
200
|
+
if counter then
|
|
201
|
+
redis.pcall("INCR", counter)
|
|
202
|
+
redis.pcall("EXPIRE", counter, ARGV[9])
|
|
203
|
+
end
|
|
204
|
+
moved = moved + 1
|
|
205
|
+
else
|
|
206
|
+
redis.call("RPUSH", KEYS[1], job)
|
|
207
|
+
failed = failed + 1
|
|
208
|
+
end
|
|
102
209
|
end
|
|
103
|
-
requeue_job(job, ARGV[1], ARGV[2], ARGV[3], ARGV[4])
|
|
104
|
-
moved = moved + 1
|
|
105
210
|
end
|
|
211
|
+
for _, job in ipairs(kept) do
|
|
212
|
+
redis.call("RPUSH", KEYS[1], job)
|
|
213
|
+
end
|
|
214
|
+
local names = {}
|
|
215
|
+
for name in pairs(unlisted) do table.insert(names, name) end
|
|
216
|
+
local reply = {moved, failed, #names}
|
|
217
|
+
for _, name in ipairs(names) do table.insert(reply, name) end
|
|
218
|
+
for _, job in ipairs(buried) do table.insert(reply, job) end
|
|
219
|
+
return reply
|
|
220
|
+
LUA
|
|
221
|
+
|
|
222
|
+
# KEYS[1] = REPEAT_ORPHANS, KEYS[2] = cogworker:dead; ARGV[1] = parked
|
|
223
|
+
# job, ARGV[2] = score, ARGV[3] = its dead entry. Moved in one step.
|
|
224
|
+
# (Head of the list only — the caller walks it in order — so no LPOS,
|
|
225
|
+
# which Redis < 6.0.6 lacks: this runs in `:basic` mode too.)
|
|
226
|
+
BURY_ORPHAN_SCRIPT = <<~LUA
|
|
227
|
+
if redis.call("LINDEX", KEYS[1], 0) ~= ARGV[1] then
|
|
228
|
+
return 0
|
|
229
|
+
end
|
|
230
|
+
redis.call("ZADD", KEYS[2], ARGV[2], ARGV[3])
|
|
231
|
+
redis.call("LPOP", KEYS[1])
|
|
232
|
+
return 1
|
|
106
233
|
LUA
|
|
107
234
|
|
|
108
235
|
# KEYS[1] = an in-progress list; ARGV[1] = one job on it, ARGV[2..5] =
|
|
109
236
|
# requeue_job's arguments. That one job, if still on the list, back on
|
|
110
237
|
# its queue: `give_back`, and `reconcile`'s strays.
|
|
238
|
+
# Present? Write, then remove — see REQUEUE_SCRIPT for why that order.
|
|
111
239
|
REQUEUE_ONE_SCRIPT = REQUEUE_JOB_FUNCTION + <<~LUA
|
|
112
|
-
if redis.call("
|
|
113
|
-
|
|
114
|
-
return 1
|
|
240
|
+
if not redis.call("LPOS", KEYS[1], ARGV[1]) then
|
|
241
|
+
return 0
|
|
115
242
|
end
|
|
116
|
-
|
|
243
|
+
requeue_job(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5])
|
|
244
|
+
redis.call("LREM", KEYS[1], 1, ARGV[1])
|
|
245
|
+
return 1
|
|
117
246
|
LUA
|
|
118
247
|
|
|
119
248
|
# KEYS[1] = in-progress list, KEYS[2] = retry/dead ZSET; ARGV[1] = the
|
|
@@ -121,13 +250,62 @@ module Cogworker
|
|
|
121
250
|
# in-progress list and into the ZSET in one step; a no-op if it's no
|
|
122
251
|
# longer in progress (already requeued by a shutdown).
|
|
123
252
|
INTERRUPT_SCRIPT = <<~LUA
|
|
124
|
-
if redis.call("
|
|
125
|
-
|
|
126
|
-
|
|
253
|
+
if not redis.call("LPOS", KEYS[1], ARGV[1]) then
|
|
254
|
+
return 0
|
|
255
|
+
end
|
|
256
|
+
redis.call("ZADD", KEYS[2], ARGV[2], ARGV[3])
|
|
257
|
+
redis.call("LREM", KEYS[1], 1, ARGV[1])
|
|
258
|
+
if KEYS[3] then redis.pcall("DEL", KEYS[3]) end -- it ran to the end: its orphan count is moot
|
|
259
|
+
return 1
|
|
260
|
+
LUA
|
|
261
|
+
|
|
262
|
+
# KEYS[1] = in-progress list, KEYS[2] = UNSETTLED; ARGV[1] = the job as
|
|
263
|
+
# fetched, ARGV[2] = its {set, score, payload} record. Present? Hand
|
|
264
|
+
# over, then remove.
|
|
265
|
+
PARK_UNSETTLED_SCRIPT = <<~LUA
|
|
266
|
+
if not redis.call("LPOS", KEYS[1], ARGV[1]) then
|
|
267
|
+
return 0
|
|
268
|
+
end
|
|
269
|
+
redis.call("RPUSH", KEYS[2], ARGV[2])
|
|
270
|
+
redis.call("LREM", KEYS[1], 1, ARGV[1])
|
|
271
|
+
return 1
|
|
272
|
+
LUA
|
|
273
|
+
|
|
274
|
+
# KEYS[1] = UNSETTLED, KEYS[2] = the job's orphan counter; ARGV[1] = one
|
|
275
|
+
# record (its head), ARGV[2..4] =
|
|
276
|
+
# set, score, payload. Filed, then removed.
|
|
277
|
+
FILE_UNSETTLED_SCRIPT = <<~LUA
|
|
278
|
+
if redis.call("LINDEX", KEYS[1], 0) ~= ARGV[1] then
|
|
279
|
+
return 0
|
|
280
|
+
end
|
|
281
|
+
redis.call("ZADD", ARGV[2], ARGV[3], ARGV[4])
|
|
282
|
+
redis.call("LPOP", KEYS[1])
|
|
283
|
+
redis.pcall("DEL", KEYS[2]) -- it ran to the end: its orphan count is moot
|
|
284
|
+
return 1
|
|
285
|
+
LUA
|
|
286
|
+
|
|
287
|
+
# KEYS[1] = cogworker:dead; ARGV[1] = an entry, ARGV[2] = score,
|
|
288
|
+
# ARGV[3] = its replacement. Swapped in one step: two commands could
|
|
289
|
+
# leave both in dead (and Retry on each from the Web UI ran it twice).
|
|
290
|
+
SWAP_DEAD_ENTRY_SCRIPT = <<~LUA
|
|
291
|
+
if not redis.call("ZSCORE", KEYS[1], ARGV[1]) then
|
|
292
|
+
return 0
|
|
127
293
|
end
|
|
128
|
-
|
|
294
|
+
redis.call("ZADD", KEYS[1], ARGV[2], ARGV[3])
|
|
295
|
+
redis.call("ZREM", KEYS[1], ARGV[1])
|
|
296
|
+
return 1
|
|
129
297
|
LUA
|
|
130
298
|
|
|
299
|
+
# KEYS[1] = in-progress list, KEYS[2] = the job's orphan counter.
|
|
300
|
+
ACK_SCRIPT = <<~LUA
|
|
301
|
+
redis.call("LREM", KEYS[1], 1, ARGV[1])
|
|
302
|
+
redis.pcall("DEL", KEYS[2])
|
|
303
|
+
return 1
|
|
304
|
+
LUA
|
|
305
|
+
|
|
306
|
+
@pending_raw_burials = []
|
|
307
|
+
@raw_burials_mutex = Mutex.new
|
|
308
|
+
|
|
131
309
|
class << self
|
|
132
310
|
# A connection error propagates: can't tell yet, so the caller
|
|
133
311
|
# (Manager#fetch_class) resolves again on its next use, instead of
|
|
@@ -137,9 +315,9 @@ module Cogworker
|
|
|
137
315
|
def supported?
|
|
138
316
|
version = Cogworker.config.redis { |c| c.info('server')['redis_version'] }
|
|
139
317
|
Gem::Version.new(version) >= MIN_REDIS_VERSION
|
|
140
|
-
rescue
|
|
141
|
-
raise
|
|
142
|
-
|
|
318
|
+
rescue StandardError => e
|
|
319
|
+
raise if RedisErrors.unavailable?(e)
|
|
320
|
+
|
|
143
321
|
true
|
|
144
322
|
end
|
|
145
323
|
|
|
@@ -148,12 +326,34 @@ module Cogworker
|
|
|
148
326
|
# owner turns out not to be dead (see REQUEUE_SCRIPT). Used without
|
|
149
327
|
# it for this process's own unfinished jobs on shutdown
|
|
150
328
|
# (`Manager#stop!`), with it for presumed-dead processes' lists.
|
|
151
|
-
|
|
152
|
-
|
|
329
|
+
#
|
|
330
|
+
# `except:` — jobs on the list that already finished but whose ack
|
|
331
|
+
# failed: dropped from it (which is their ack) rather than requeued,
|
|
332
|
+
# in the same atomic step. `keep:` — jobs left on the list untouched.
|
|
333
|
+
def requeue_in_progress(identity, if_dead_for: nil, except: [], keep: [])
|
|
334
|
+
moved, failed, unlisted_count, *rest = Cogworker.config.redis do |c|
|
|
153
335
|
LuaScript.run(c, REQUEUE_SCRIPT,
|
|
154
|
-
|
|
155
|
-
|
|
336
|
+
keys: [RedisKeys.in_progress(identity), RedisKeys.process(identity), RedisKeys::LAST_BEAT,
|
|
337
|
+
RedisKeys::REPEAT_ORPHANS, RedisKeys::DEAD, RedisKeys::QUEUES, RedisKeys::LIVE_QUEUES],
|
|
338
|
+
argv: [*requeue_args, identity, if_dead_for.to_s, Time.now.to_i,
|
|
339
|
+
RedisKeys::ORPHANINGS_PREFIX, ORPHANINGS_TTL, Cogworker.config.max_orphanings,
|
|
340
|
+
*except.map { |raw| "d:#{raw}" }, *keep.map { |raw| "k:#{raw}" }])
|
|
156
341
|
end
|
|
342
|
+
unlisted = rest.shift(unlisted_count)
|
|
343
|
+
complete_raw_burials(rest)
|
|
344
|
+
unless unlisted.empty?
|
|
345
|
+
Cogworker.logger.warn do
|
|
346
|
+
"requeued job(s) from #{identity} onto queue(s) no live process reads: #{unlisted.join(', ')} " \
|
|
347
|
+
'— make sure some worker reads them'
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
if failed.positive?
|
|
351
|
+
Cogworker.logger.error do
|
|
352
|
+
"#{failed} job(s) left in progress by #{identity} couldn't be put back on their queue " \
|
|
353
|
+
'(a key of the wrong type?) — kept, retried on the next pass'
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
moved
|
|
157
357
|
end
|
|
158
358
|
|
|
159
359
|
def requeue_args
|
|
@@ -174,40 +374,235 @@ module Cogworker
|
|
|
174
374
|
# reliable-fetch process registers itself there on every beat) rather
|
|
175
375
|
# than SCANning the keyspace; `scan: true` (once per reliable process,
|
|
176
376
|
# at boot) also picks up lists from before that set existed.
|
|
177
|
-
|
|
377
|
+
#
|
|
378
|
+
# One identity that can't be processed (its list of the wrong type, a
|
|
379
|
+
# corrupt stamp, ...) is logged and skipped — it must never stop the
|
|
380
|
+
# others, let alone the poller or the cron ticker waiting on it.
|
|
381
|
+
#
|
|
382
|
+
# `report:` (a Hash, if given) gets `:registry` / `:scan` set to
|
|
383
|
+
# whether reading that source of candidates worked — what tells the
|
|
384
|
+
# caller a pass really looked, as opposed to one that just survived.
|
|
385
|
+
def recover_orphans(scan: false, report: {})
|
|
178
386
|
own = Cogworker.identity
|
|
179
|
-
candidate_identities(scan: scan).sum do |identity|
|
|
387
|
+
moved = candidate_identities(scan: scan, report: report).sum do |identity|
|
|
180
388
|
next 0 if identity == own
|
|
181
389
|
|
|
182
|
-
|
|
183
|
-
|
|
390
|
+
recover_identity(identity)
|
|
391
|
+
rescue StandardError => e
|
|
392
|
+
report[:connection_lost] = true if RedisErrors.unavailable?(e)
|
|
393
|
+
RedisErrors.report('Orphan recovery', e) { "skipped #{identity}" }
|
|
394
|
+
0
|
|
395
|
+
end
|
|
396
|
+
isolated('burying repeat orphans', nil, report) { bury_repeat_orphans }
|
|
397
|
+
isolated('completing dead entries', nil, report) { retry_raw_burials }
|
|
398
|
+
isolated('filing unsettled jobs', nil, report) { file_unsettled }
|
|
399
|
+
RedisErrors.recovered('Orphan recovery') unless report[:connection_lost]
|
|
400
|
+
moved
|
|
401
|
+
end
|
|
184
402
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
403
|
+
# Files every job REQUEUE_SCRIPT parked (orphaned more than
|
|
404
|
+
# `config.max_orphanings` times) in `dead`, with an error saying why — whatever
|
|
405
|
+
# its `retry` setting, since each of those runs ended with its process
|
|
406
|
+
# gone rather than an error to retry on. Parked entries survive a
|
|
407
|
+
# failure here and are picked up on the next pass.
|
|
408
|
+
def bury_repeat_orphans
|
|
409
|
+
Cogworker.config.redis do |c|
|
|
410
|
+
c.lrange(RedisKeys::REPEAT_ORPHANS, 0, -1).each do |raw|
|
|
411
|
+
job = parse_hash(raw) || JobUtil.unparseable_job(raw, queue: nil, error: JSON::ParserError.new('unreadable'))
|
|
412
|
+
job.merge!(repeat_orphan_error)
|
|
413
|
+
buried = LuaScript.run(c, BURY_ORPHAN_SCRIPT, keys: [RedisKeys::REPEAT_ORPHANS, RedisKeys::DEAD],
|
|
414
|
+
argv: [raw, Time.now.to_f, JSON.generate(job)])
|
|
415
|
+
next unless buried == 1
|
|
416
|
+
|
|
417
|
+
Cogworker.logger.error { "jid=#{job['jid']} orphaned too often, moved to dead" }
|
|
418
|
+
buried_as_dead(c, job)
|
|
419
|
+
end
|
|
188
420
|
end
|
|
189
421
|
end
|
|
190
422
|
|
|
423
|
+
def recover_identity(identity)
|
|
424
|
+
moved = requeue_in_progress(identity, if_dead_for: Cogworker.config.orphan_threshold)
|
|
425
|
+
return 0 if moved.negative? # alive after all: keep its registration and last beat
|
|
426
|
+
|
|
427
|
+
isolated("forgetting #{identity}", nil) { forget_if_empty(identity) } # its jobs are already back
|
|
428
|
+
if moved.positive?
|
|
429
|
+
Cogworker.logger.warn do
|
|
430
|
+
"requeued #{moved} job(s) left in progress by dead process #{identity}"
|
|
431
|
+
end
|
|
432
|
+
end
|
|
433
|
+
moved
|
|
434
|
+
end
|
|
435
|
+
|
|
191
436
|
# This process's own in-progress entries that no processor thread is
|
|
192
|
-
# running
|
|
193
|
-
#
|
|
194
|
-
#
|
|
195
|
-
#
|
|
196
|
-
# in
|
|
197
|
-
#
|
|
198
|
-
|
|
437
|
+
# running — judged by the process's own in-memory record (`running:`,
|
|
438
|
+
# from the Manager), never by `cogworker:workers` in Redis, which a
|
|
439
|
+
# failover can lose while a long job is still running. An entry whose
|
|
440
|
+
# job finished but isn't settled yet (`pending:` — its ack failed, or
|
|
441
|
+
# filing its failure in retry/dead did) gets that finished here and is
|
|
442
|
+
# yielded (so the caller can forget it) — never re-run. Any other
|
|
443
|
+
# stray is requeued once seen on two calls in a row (the caller passes
|
|
444
|
+
# back what the previous call returned): the second look rules out a
|
|
445
|
+
# job caught between being fetched and being recorded as running.
|
|
446
|
+
# Returns this call's strays not requeued yet.
|
|
447
|
+
def reconcile(identity, previous_strays = [], running: [], pending: {}, &settled)
|
|
448
|
+
key = RedisKeys.in_progress(identity)
|
|
449
|
+
listed = Cogworker.config.redis { |c| c.lrange(key, 0, -1) }
|
|
450
|
+
strays = listed - running
|
|
451
|
+
finished, strays = strays.partition { |raw| pending.key?(raw) }
|
|
452
|
+
settle_pending(identity, pending.slice(*finished), &settled)
|
|
199
453
|
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
454
|
(strays & previous_strays).each do |raw|
|
|
204
|
-
next unless LuaScript.run(c, REQUEUE_ONE_SCRIPT, keys: [
|
|
205
|
-
argv: [raw, *requeue_args]) == 1
|
|
455
|
+
next unless LuaScript.run(c, REQUEUE_ONE_SCRIPT, keys: [key], argv: [raw, *requeue_args]) == 1
|
|
206
456
|
|
|
207
457
|
Cogworker.logger.warn { "requeued stray in-progress job jid=#{parse_hash(raw)&.fetch('jid', nil)}" }
|
|
208
458
|
end
|
|
209
|
-
strays - previous_strays
|
|
210
459
|
end
|
|
460
|
+
strays - previous_strays
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
# Finishes each pending settlement (`raw => :ack | [set, score,
|
|
464
|
+
# payload]`) that can be finished now, yielding each one done. One
|
|
465
|
+
# that fails is logged and stays pending. (Filing one doesn't repeat
|
|
466
|
+
# the rest of the failure path, and needn't: `stats:failed` was
|
|
467
|
+
# counted when the job failed, and a terminal failure's unique lock
|
|
468
|
+
# already released by UniqueJobs::ReleaseMiddleware in the chain. Nor
|
|
469
|
+
# are locks extended over an interrupt's delay — at most
|
|
470
|
+
# Processor::INTERRUPT_DELAY * MAX_INTERRUPTS, well inside
|
|
471
|
+
# RunningLock.active_ttl.)
|
|
472
|
+
def settle_pending(identity, pending)
|
|
473
|
+
key = RedisKeys.in_progress(identity)
|
|
474
|
+
pending.each do |raw, how|
|
|
475
|
+
Cogworker.config.redis do |c|
|
|
476
|
+
if how == :ack
|
|
477
|
+
jid = parse_hash(raw)&.fetch('jid', nil)
|
|
478
|
+
LuaScript.run(c, ACK_SCRIPT, keys: [key, RedisKeys::ORPHANINGS_PREFIX + jid.to_s], argv: [raw])
|
|
479
|
+
else
|
|
480
|
+
set, score, payload = how
|
|
481
|
+
filed = LuaScript.run(c, INTERRUPT_SCRIPT, keys: [key, set, orphanings_key(raw)],
|
|
482
|
+
argv: [raw, score, payload])
|
|
483
|
+
released_if_dead(c, set, payload) if filed == 1
|
|
484
|
+
end
|
|
485
|
+
end
|
|
486
|
+
yield raw if block_given?
|
|
487
|
+
rescue StandardError => e
|
|
488
|
+
Cogworker.logger.error do
|
|
489
|
+
"still couldn't settle jid=#{parse_hash(raw)&.fetch('jid', nil)}: #{e.class}: #{e.message}"
|
|
490
|
+
end
|
|
491
|
+
end
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
# On shutdown (`Manager#requeue_unfinished`): a job that ran and
|
|
495
|
+
# failed but couldn't be filed in retry/dead even now is handed over
|
|
496
|
+
# to UNSETTLED with its record, so any live process can file it later
|
|
497
|
+
# (`file_unsettled`) — rather than requeued with the rest, which ran a
|
|
498
|
+
# `retry: false` job again on every deploy. Returns the jobs that
|
|
499
|
+
# couldn't even be handed over.
|
|
500
|
+
def park_unsettled(identity, pending)
|
|
501
|
+
pending.reject do |raw, (set, score, payload)|
|
|
502
|
+
record = JSON.generate('set' => set, 'score' => score, 'payload' => payload)
|
|
503
|
+
Cogworker.config.redis do |c|
|
|
504
|
+
LuaScript.run(c, PARK_UNSETTLED_SCRIPT, keys: [RedisKeys.in_progress(identity), RedisKeys::UNSETTLED],
|
|
505
|
+
argv: [raw, record])
|
|
506
|
+
end
|
|
507
|
+
true
|
|
508
|
+
rescue StandardError => e
|
|
509
|
+
Cogworker.logger.error do
|
|
510
|
+
"couldn't hand over unsettled jid=#{parse_hash(raw)&.fetch('jid', nil)}: #{e.class}: #{e.message}"
|
|
511
|
+
end
|
|
512
|
+
false
|
|
513
|
+
end.keys
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
# Files what shutting-down processes handed over in UNSETTLED, head
|
|
517
|
+
# first, stopping at the first that still can't be filed (retried on
|
|
518
|
+
# the next pass).
|
|
519
|
+
def file_unsettled
|
|
520
|
+
Cogworker.config.redis do |c|
|
|
521
|
+
c.lrange(RedisKeys::UNSETTLED, 0, -1).each do |record|
|
|
522
|
+
entry = parse_hash(record)
|
|
523
|
+
next quarantine_record(c, record) unless valid_unsettled?(entry)
|
|
524
|
+
|
|
525
|
+
filed = LuaScript.run(c, FILE_UNSETTLED_SCRIPT,
|
|
526
|
+
keys: [RedisKeys::UNSETTLED, orphanings_key(entry['payload'])],
|
|
527
|
+
argv: [record, entry['set'], entry['score'], entry['payload']])
|
|
528
|
+
released_if_dead(c, entry['set'], entry['payload']) if filed == 1
|
|
529
|
+
end
|
|
530
|
+
end
|
|
531
|
+
end
|
|
532
|
+
|
|
533
|
+
# Jobs REQUEUE_SCRIPT had to file in dead raw (its parking list
|
|
534
|
+
# unusable): swapped for a proper dead entry, and given the usual
|
|
535
|
+
# terminal-failure side effects. Best-effort, per job.
|
|
536
|
+
# A swap that fails (Redis away) is kept and retried on the next pass
|
|
537
|
+
# (`recover_orphans`) — left alone, the raw entry stayed in dead without
|
|
538
|
+
# its error, its job's locks held and its failure uncounted.
|
|
539
|
+
def complete_raw_burials(raws)
|
|
540
|
+
raws.each do |raw|
|
|
541
|
+
job = (parse_hash(raw) || {}).merge(repeat_orphan_error)
|
|
542
|
+
Cogworker.config.redis do |c|
|
|
543
|
+
swapped = LuaScript.run(c, SWAP_DEAD_ENTRY_SCRIPT, keys: [RedisKeys::DEAD],
|
|
544
|
+
argv: [raw, Time.now.to_f, JSON.generate(job)])
|
|
545
|
+
buried_as_dead(c, job) if swapped == 1
|
|
546
|
+
end
|
|
547
|
+
@raw_burials_mutex.synchronize { @pending_raw_burials.delete(raw) }
|
|
548
|
+
rescue StandardError => e
|
|
549
|
+
@raw_burials_mutex.synchronize { @pending_raw_burials << raw unless @pending_raw_burials.include?(raw) }
|
|
550
|
+
RedisErrors.report('Orphan recovery', e) { "a repeat orphan's dead entry not completed (will retry)" }
|
|
551
|
+
end
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
def retry_raw_burials
|
|
555
|
+
complete_raw_burials(@raw_burials_mutex.synchronize { @pending_raw_burials.dup })
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
def repeat_orphan_error
|
|
559
|
+
{ 'error_class' => 'Cogworker::ProcessDied', 'failed_at' => Time.now.to_f,
|
|
560
|
+
'error_message' => "its process died while running it more than #{Cogworker.config.max_orphanings} times " \
|
|
561
|
+
'(killed for memory? SIGKILL?) — not retried again' }
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
FILEABLE_SETS = [RedisKeys::RETRY, RedisKeys::DEAD, RedisKeys::SCHEDULE].freeze
|
|
565
|
+
|
|
566
|
+
# `set` must be one of ours to file into — not any key a damaged record
|
|
567
|
+
# names (another type's, which would fail every pass at that record).
|
|
568
|
+
# Filing a failure in dead outside the middleware chain: release its
|
|
569
|
+
# locks, as the chain would for a terminal failure.
|
|
570
|
+
def released_if_dead(conn, set, payload)
|
|
571
|
+
job = parse_hash(payload)
|
|
572
|
+
JobUtil.release_terminal_locks(conn, job) if set == RedisKeys::DEAD && job
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
def valid_unsettled?(entry)
|
|
576
|
+
entry.is_a?(Hash) && FILEABLE_SETS.include?(entry['set']) && entry['score'].is_a?(Numeric) &&
|
|
577
|
+
entry['payload'].is_a?(String)
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
# A record that can't be filed (unreadable, fields missing) is moved
|
|
581
|
+
# out of the way — kept for a human, like KeyGuard's quarantine —
|
|
582
|
+
# instead of stopping every pass at it, and everything behind it.
|
|
583
|
+
def quarantine_record(conn, record)
|
|
584
|
+
target = "#{KeyGuard::QUARANTINE_PREFIX}#{RedisKeys::UNSETTLED}:records"
|
|
585
|
+
conn.rpush(target, record)
|
|
586
|
+
conn.expire(target, KeyGuard::QUARANTINE_TTL)
|
|
587
|
+
conn.lrem(RedisKeys::UNSETTLED, 1, record)
|
|
588
|
+
Cogworker.logger.error { "unusable #{RedisKeys::UNSETTLED} record moved to #{target}: #{record.to_s[0, 200]}" }
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
# What any other terminal failure does on its way to dead: counted as
|
|
592
|
+
# failed, and its `until_executed` locks released (so the entry / the
|
|
593
|
+
# unique key isn't blocked until the locks' TTL). Best-effort.
|
|
594
|
+
# Each step on its own, locks first: one failing (a key of the wrong
|
|
595
|
+
# type) must not leave the others undone.
|
|
596
|
+
def buried_as_dead(conn, job)
|
|
597
|
+
JobUtil.release_terminal_locks(conn, job)
|
|
598
|
+
BestEffort.call('Orphan counter') { conn.del(RedisKeys::ORPHANINGS_PREFIX + job['jid'].to_s) }
|
|
599
|
+
BestEffort.call('Stats') { conn.incr(RedisKeys::STATS_FAILED) }
|
|
600
|
+
BestEffort.call('Throughput') { Throughput.record('failed') }
|
|
601
|
+
end
|
|
602
|
+
|
|
603
|
+
def orphanings_key(raw)
|
|
604
|
+
jid = parse_hash(raw)&.fetch('jid', nil) # (nil for an unreadable payload: an unused key then)
|
|
605
|
+
"#{RedisKeys::ORPHANINGS_PREFIX}#{jid}"
|
|
211
606
|
end
|
|
212
607
|
|
|
213
608
|
def parse_hash(raw)
|
|
@@ -217,16 +612,30 @@ module Cogworker
|
|
|
217
612
|
nil
|
|
218
613
|
end
|
|
219
614
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
615
|
+
# Each source on its own: the registry set unreadable (wrong type)
|
|
616
|
+
# mustn't also lose the one-off scan, and vice versa.
|
|
617
|
+
def candidate_identities(scan:, report: {})
|
|
618
|
+
registered = isolated('reading the in-progress registry', nil, report) do
|
|
619
|
+
Cogworker.config.redis { |c| c.smembers(RedisKeys::IN_PROGRESS_IDENTITIES) }
|
|
620
|
+
end
|
|
621
|
+
report[:registry] = !registered.nil?
|
|
622
|
+
return registered.to_a unless scan
|
|
224
623
|
|
|
225
|
-
|
|
226
|
-
|
|
624
|
+
scanned = isolated('scanning for in-progress lists', nil, report) do
|
|
625
|
+
Cogworker.config.redis do |c|
|
|
626
|
+
c.scan_each(match: "#{RedisKeys::IN_PROGRESS_PREFIX}*").map { |key| key.delete_prefix(RedisKeys::IN_PROGRESS_PREFIX) }
|
|
227
627
|
end
|
|
228
|
-
identities | scanned
|
|
229
628
|
end
|
|
629
|
+
report[:scan] = !scanned.nil?
|
|
630
|
+
registered.to_a | scanned.to_a
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
def isolated(what, fallback, report = nil)
|
|
634
|
+
yield
|
|
635
|
+
rescue StandardError => e
|
|
636
|
+
report[:connection_lost] = true if report && RedisErrors.unavailable?(e)
|
|
637
|
+
RedisErrors.report('Orphan recovery', e) { "#{what} failed" }
|
|
638
|
+
fallback
|
|
230
639
|
end
|
|
231
640
|
|
|
232
641
|
def forget_if_empty(identity)
|
|
@@ -266,19 +675,27 @@ module Cogworker
|
|
|
266
675
|
def give_back(work)
|
|
267
676
|
Cogworker.config.redis do |c|
|
|
268
677
|
LuaScript.run(c, REQUEUE_ONE_SCRIPT, keys: [RedisKeys.in_progress(Cogworker.identity)],
|
|
269
|
-
|
|
678
|
+
argv: [work.raw_job, *self.class.requeue_args])
|
|
270
679
|
end
|
|
271
680
|
end
|
|
272
681
|
|
|
273
682
|
def interrupt(work, set, score, payload)
|
|
274
683
|
Cogworker.config.redis do |c|
|
|
275
|
-
LuaScript.run(c, INTERRUPT_SCRIPT, keys: [RedisKeys.in_progress(Cogworker.identity), set
|
|
276
|
-
|
|
684
|
+
LuaScript.run(c, INTERRUPT_SCRIPT, keys: [RedisKeys.in_progress(Cogworker.identity), set,
|
|
685
|
+
self.class.orphanings_key(work.raw_job)],
|
|
686
|
+
argv: [work.raw_job, score, payload])
|
|
277
687
|
end
|
|
278
688
|
end
|
|
279
689
|
|
|
690
|
+
# Also clears the job's orphan counter: it got to finish, so whatever
|
|
691
|
+
# crashes it sat through before weren't its doing (or are behind it).
|
|
280
692
|
def acknowledge(work)
|
|
281
|
-
|
|
693
|
+
jid = self.class.parse_hash(work.raw_job)&.fetch('jid', nil)
|
|
694
|
+
Cogworker.config.redis do |c|
|
|
695
|
+
LuaScript.run(c, ACK_SCRIPT, keys: [RedisKeys.in_progress(Cogworker.identity),
|
|
696
|
+
RedisKeys::ORPHANINGS_PREFIX + jid.to_s],
|
|
697
|
+
argv: [work.raw_job])
|
|
698
|
+
end
|
|
282
699
|
end
|
|
283
700
|
|
|
284
701
|
private
|