cogworker 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/cogworker/attempts.rb +1 -1
- data/lib/cogworker/basic_fetch.rb +26 -5
- data/lib/cogworker/config.rb +13 -1
- data/lib/cogworker/heartbeat.rb +33 -10
- data/lib/cogworker/history/storage.rb +2 -2
- data/lib/cogworker/job_util.rb +49 -9
- data/lib/cogworker/key_guard.rb +92 -0
- data/lib/cogworker/launcher.rb +8 -1
- data/lib/cogworker/lua_script.rb +30 -0
- data/lib/cogworker/manager.rb +63 -6
- data/lib/cogworker/owned_key.rb +2 -2
- data/lib/cogworker/periodic/ticker.rb +122 -25
- data/lib/cogworker/processor.rb +194 -59
- data/lib/cogworker/redis_keys.rb +9 -0
- data/lib/cogworker/reliable_fetch.rb +480 -87
- data/lib/cogworker/scheduled.rb +100 -14
- data/lib/cogworker/status/server_middleware.rb +1 -1
- data/lib/cogworker/version.rb +1 -1
- data/lib/cogworker/web/optional_session.rb +7 -0
- data/lib/cogworker/web.rb +5 -4
- metadata +3 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'digest/sha1'
|
|
4
|
+
require 'json'
|
|
4
5
|
|
|
5
6
|
module Cogworker
|
|
6
7
|
# The default fetch (`config.fetch = :reliable`): every job is moved
|
|
@@ -27,6 +28,7 @@ module Cogworker
|
|
|
27
28
|
# on an idle process — the trade-off against how often idle processors
|
|
28
29
|
# poll Redis (at 0.25s flat, 4 calls/s per thread).
|
|
29
30
|
EMPTY_POLL_INTERVAL = 0.25
|
|
31
|
+
ORPHANINGS_TTL = 7 * 24 * 60 * 60
|
|
30
32
|
MIN_REDIS_VERSION = Gem::Version.new('6.2')
|
|
31
33
|
|
|
32
34
|
# KEYS[1..n-1] = queue keys, in the order to try; KEYS[n] = in-progress list.
|
|
@@ -41,121 +43,487 @@ module Cogworker
|
|
|
41
43
|
return false
|
|
42
44
|
LUA
|
|
43
45
|
|
|
44
|
-
#
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
# the next slot alongside the recovered run.
|
|
58
|
-
REQUEUE_SCRIPT = Periodic::RunningLock::RETAKE_FUNCTION + <<~LUA
|
|
59
|
-
local moved = 0
|
|
60
|
-
while true do
|
|
61
|
-
local job = redis.call("LPOP", KEYS[1])
|
|
62
|
-
if not job then
|
|
63
|
-
return moved
|
|
64
|
-
end
|
|
65
|
-
local queue = ARGV[2]
|
|
46
|
+
# (Run by hash, like every script here — see LuaScript.)
|
|
47
|
+
FETCH_SCRIPT_SHA = LuaScript.sha(FETCH_SCRIPT)
|
|
48
|
+
|
|
49
|
+
# Lua shared by every script here that puts a job back on its queue:
|
|
50
|
+
# onto the end its queue is popped from (so it runs next), onto
|
|
51
|
+
# `default_queue` if its own `queue` can't be read (the Processor then
|
|
52
|
+
# buries it as unparseable) — and a job from an `until_executed`
|
|
53
|
+
# periodic entry re-takes its running lock on the way
|
|
54
|
+
# (Periodic::RunningLock::RETAKE_FUNCTION), so the ticker can't start
|
|
55
|
+
# the next slot alongside the requeued run.
|
|
56
|
+
REQUEUE_JOB_FUNCTION = Periodic::RunningLock::RETAKE_FUNCTION + <<~LUA
|
|
57
|
+
local function requeue_job(job, queue_prefix, default_queue, lock_prefix, lock_ttl)
|
|
58
|
+
local queue = default_queue
|
|
66
59
|
local ok, decoded = pcall(cjson.decode, job)
|
|
67
60
|
if ok and type(decoded) == "table" then
|
|
68
|
-
if type(decoded["queue"]) == "string" then
|
|
61
|
+
if type(decoded["queue"]) == "string" and decoded["queue"] ~= "" then
|
|
69
62
|
queue = decoded["queue"]
|
|
70
63
|
end
|
|
71
64
|
local pjid, jid = decoded["periodic_pjid"], decoded["jid"]
|
|
72
65
|
if decoded["periodic_until_executed"] == true and type(pjid) == "string" and type(jid) == "string" then
|
|
73
|
-
retake_running_lock(
|
|
66
|
+
retake_running_lock(lock_prefix .. pjid, jid, lock_ttl)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
redis.call("RPUSH", queue_prefix .. queue, job)
|
|
70
|
+
end
|
|
71
|
+
LUA
|
|
72
|
+
|
|
73
|
+
# KEYS[1] = an in-progress list, KEYS[2] = its owner's presence key,
|
|
74
|
+
# KEYS[3] = LAST_BEAT; ARGV[1..4] = requeue_job's prefix/default
|
|
75
|
+
# queue/lock prefix/lock TTL, ARGV[5] = owner identity, ARGV[6] = orphan
|
|
76
|
+
# threshold, or "" to requeue unconditionally (this process's own list,
|
|
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
|
|
86
|
+
# beat in between would lose jobs it is still running to a second run.
|
|
87
|
+
# Atomic per call, so two processes recovering one list can't both move
|
|
88
|
+
# a job.
|
|
89
|
+
REQUEUE_SCRIPT = REQUEUE_JOB_FUNCTION + <<~LUA
|
|
90
|
+
if ARGV[6] ~= "" then
|
|
91
|
+
if redis.call("EXISTS", KEYS[2]) == 1 then
|
|
92
|
+
return {-1}
|
|
93
|
+
end
|
|
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 "")
|
|
98
|
+
if last then
|
|
99
|
+
if redis.replicate_commands then redis.replicate_commands() end
|
|
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}
|
|
74
105
|
end
|
|
75
106
|
end
|
|
76
|
-
redis.call("RPUSH", ARGV[1] .. queue, job)
|
|
77
|
-
moved = moved + 1
|
|
78
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
|
|
117
|
+
local moved = 0
|
|
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)
|
|
129
|
+
if not job then
|
|
130
|
+
break
|
|
131
|
+
end
|
|
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)
|
|
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
|
|
79
205
|
LUA
|
|
80
206
|
|
|
81
|
-
# KEYS[1] = in-progress list
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
207
|
+
# KEYS[1] = an in-progress list; ARGV[1] = one job on it, ARGV[2..5] =
|
|
208
|
+
# requeue_job's arguments. That one job, if still on the list, back on
|
|
209
|
+
# its queue: `give_back`, and `reconcile`'s strays.
|
|
210
|
+
# Present? Write, then remove — see REQUEUE_SCRIPT for why that order.
|
|
211
|
+
REQUEUE_ONE_SCRIPT = REQUEUE_JOB_FUNCTION + <<~LUA
|
|
212
|
+
if not redis.call("LPOS", KEYS[1], ARGV[1]) then
|
|
213
|
+
return 0
|
|
85
214
|
end
|
|
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
|
|
218
|
+
LUA
|
|
219
|
+
|
|
220
|
+
# KEYS[1] = in-progress list, KEYS[2] = retry/dead ZSET; ARGV[1] = the
|
|
221
|
+
# job as fetched, ARGV[2] = score, ARGV[3] = payload to file. Off the
|
|
222
|
+
# in-progress list and into the ZSET in one step; a no-op if it's no
|
|
223
|
+
# longer in progress (already requeued by a shutdown).
|
|
224
|
+
INTERRUPT_SCRIPT = <<~LUA
|
|
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
|
|
250
|
+
end
|
|
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
|
|
86
261
|
LUA
|
|
87
262
|
|
|
88
263
|
class << self
|
|
264
|
+
# A connection error propagates: can't tell yet, so the caller
|
|
265
|
+
# (Manager#fetch_class) resolves again on its next use, instead of
|
|
266
|
+
# memoizing a guess made while Redis was unreachable. Anything else
|
|
267
|
+
# (INFO restricted, an odd version string) assumes a current server —
|
|
268
|
+
# the runtime fall-back (UnsupportedError) still catches a wrong guess.
|
|
89
269
|
def supported?
|
|
90
270
|
version = Cogworker.config.redis { |c| c.info('server')['redis_version'] }
|
|
91
271
|
Gem::Version.new(version) >= MIN_REDIS_VERSION
|
|
272
|
+
rescue Redis::BaseConnectionError
|
|
273
|
+
raise
|
|
92
274
|
rescue StandardError
|
|
93
|
-
true
|
|
275
|
+
true
|
|
94
276
|
end
|
|
95
277
|
|
|
96
278
|
# Puts every job still on `identity`'s in-progress list back on its
|
|
97
|
-
# queue; returns how many
|
|
98
|
-
#
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
279
|
+
# queue; returns how many — or -1 if `if_dead_for:` is given and the
|
|
280
|
+
# owner turns out not to be dead (see REQUEUE_SCRIPT). Used without
|
|
281
|
+
# it for this process's own unfinished jobs on shutdown
|
|
282
|
+
# (`Manager#stop!`), with it for presumed-dead processes' lists.
|
|
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|
|
|
289
|
+
LuaScript.run(c, REQUEUE_SCRIPT,
|
|
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}" }])
|
|
295
|
+
end
|
|
296
|
+
complete_raw_burials(buried)
|
|
297
|
+
moved
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def requeue_args
|
|
301
|
+
[RedisKeys::QUEUE_PREFIX, 'default', RedisKeys.periodic_running(''), Periodic::RunningLock.queued_ttl]
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# Requeues the in-progress list of every process that has died without
|
|
305
|
+
# a clean shutdown. "Dead" means its presence key is gone (no beat for
|
|
306
|
+
# Heartbeat::TTL) *and* `config.orphan_threshold` has passed since its
|
|
307
|
+
# last recorded beat (LAST_BEAT, on Redis's clock) — the presence key
|
|
308
|
+
# alone expires after a minute, too eagerly for a live process that
|
|
309
|
+
# merely couldn't beat for a while (a Redis outage or failover, a long
|
|
310
|
+
# GVL-holding call), whose running jobs would then run twice. With no
|
|
311
|
+
# recorded beat at all (lists from before LAST_BEAT existed), the
|
|
312
|
+
# presence key alone decides. Checked inside REQUEUE_SCRIPT, atomically
|
|
313
|
+
# with the requeue. A process always beats before its first fetch
|
|
314
|
+
# (`Heartbeat#start!`). Walks IN_PROGRESS_IDENTITIES (each
|
|
315
|
+
# reliable-fetch process registers itself there on every beat) rather
|
|
316
|
+
# than SCANning the keyspace; `scan: true` (once per reliable process,
|
|
317
|
+
# at boot) also picks up lists from before that set existed.
|
|
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: {})
|
|
117
327
|
own = Cogworker.identity
|
|
118
|
-
candidate_identities(scan: scan).sum do |identity|
|
|
119
|
-
next 0 if identity == own
|
|
328
|
+
moved = candidate_identities(scan: scan, report: report).sum do |identity|
|
|
329
|
+
next 0 if identity == own
|
|
120
330
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
|
124
341
|
|
|
125
|
-
|
|
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)
|
|
126
358
|
end
|
|
127
359
|
end
|
|
128
360
|
end
|
|
129
361
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
+
|
|
371
|
+
# This process's own in-progress entries that no processor thread is
|
|
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)
|
|
139
388
|
Cogworker.config.redis do |c|
|
|
140
|
-
|
|
389
|
+
(strays & previous_strays).each do |raw|
|
|
390
|
+
next unless LuaScript.run(c, REQUEUE_ONE_SCRIPT, keys: [key], argv: [raw, *requeue_args]) == 1
|
|
391
|
+
|
|
392
|
+
Cogworker.logger.warn { "requeued stray in-progress job jid=#{parse_hash(raw)&.fetch('jid', nil)}" }
|
|
393
|
+
end
|
|
394
|
+
end
|
|
395
|
+
strays - previous_strays
|
|
396
|
+
end
|
|
141
397
|
|
|
142
|
-
|
|
143
|
-
|
|
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}" }
|
|
144
422
|
end
|
|
145
423
|
end
|
|
146
424
|
|
|
147
|
-
|
|
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
|
|
148
449
|
Cogworker.config.redis do |c|
|
|
149
|
-
|
|
150
|
-
|
|
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
|
|
151
453
|
|
|
152
|
-
|
|
153
|
-
|
|
454
|
+
LuaScript.run(c, FILE_UNSETTLED_SCRIPT, keys: [RedisKeys::UNSETTLED],
|
|
455
|
+
argv: [record, entry['set'], entry['score'], entry['payload']])
|
|
154
456
|
end
|
|
155
|
-
identities | scanned
|
|
156
457
|
end
|
|
157
458
|
end
|
|
158
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}" }
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
def parse_hash(raw)
|
|
495
|
+
value = JSON.parse(raw)
|
|
496
|
+
value.is_a?(Hash) ? value : nil
|
|
497
|
+
rescue JSON::ParserError
|
|
498
|
+
nil
|
|
499
|
+
end
|
|
500
|
+
|
|
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
|
|
509
|
+
|
|
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) }
|
|
513
|
+
end
|
|
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
|
|
525
|
+
end
|
|
526
|
+
|
|
159
527
|
def forget_if_empty(identity)
|
|
160
528
|
Cogworker.config.redis do |c|
|
|
161
529
|
next unless c.llen(RedisKeys.in_progress(identity)).zero?
|
|
@@ -169,10 +537,13 @@ module Cogworker
|
|
|
169
537
|
def retrieve_work
|
|
170
538
|
keys = active_keys
|
|
171
539
|
if keys.empty?
|
|
172
|
-
|
|
540
|
+
interruptible_sleep(TIMEOUT)
|
|
173
541
|
return nil
|
|
174
542
|
end
|
|
175
543
|
|
|
544
|
+
distinct = keys.uniq
|
|
545
|
+
return blocking_fetch(distinct.first) if distinct.size == 1
|
|
546
|
+
|
|
176
547
|
result = run_fetch_script(keys.shuffle.uniq + [RedisKeys.in_progress(Cogworker.identity)])
|
|
177
548
|
unless result
|
|
178
549
|
idle_sleep
|
|
@@ -189,25 +560,47 @@ module Cogworker
|
|
|
189
560
|
# no longer in progress (already requeued by `requeue_in_progress`).
|
|
190
561
|
def give_back(work)
|
|
191
562
|
Cogworker.config.redis do |c|
|
|
192
|
-
|
|
193
|
-
|
|
563
|
+
LuaScript.run(c, REQUEUE_ONE_SCRIPT, keys: [RedisKeys.in_progress(Cogworker.identity)],
|
|
564
|
+
argv: [work.raw_job, *self.class.requeue_args])
|
|
194
565
|
end
|
|
195
566
|
end
|
|
196
567
|
|
|
568
|
+
def interrupt(work, set, score, payload)
|
|
569
|
+
Cogworker.config.redis do |c|
|
|
570
|
+
LuaScript.run(c, INTERRUPT_SCRIPT, keys: [RedisKeys.in_progress(Cogworker.identity), set],
|
|
571
|
+
argv: [work.raw_job, score, payload])
|
|
572
|
+
end
|
|
573
|
+
end
|
|
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).
|
|
197
577
|
def acknowledge(work)
|
|
198
|
-
|
|
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
|
|
199
584
|
end
|
|
200
585
|
|
|
201
586
|
private
|
|
202
587
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
c.eval(FETCH_SCRIPT, keys: keys) # loads it into the script cache for next time
|
|
588
|
+
# With a single (unpaused) queue there's no weighting to honor, so no
|
|
589
|
+
# need to poll: a blocking BLMOVE waits on the server and returns the
|
|
590
|
+
# moment a job arrives, for up to TIMEOUT — same as BasicFetch's BRPOP.
|
|
591
|
+
def blocking_fetch(queue_key)
|
|
592
|
+
raw_job = Cogworker.config.redis do |c|
|
|
593
|
+
c.blmove(queue_key, RedisKeys.in_progress(Cogworker.identity), 'RIGHT', 'LEFT', timeout: TIMEOUT)
|
|
210
594
|
end
|
|
595
|
+
raw_job && UnitOfWork.new(queue_key.delete_prefix(RedisKeys::QUEUE_PREFIX), raw_job)
|
|
596
|
+
rescue Redis::CommandError => e
|
|
597
|
+
raise unless e.message.match?(/unknown (redis )?command/i)
|
|
598
|
+
|
|
599
|
+
raise UnsupportedError, e.message
|
|
600
|
+
end
|
|
601
|
+
|
|
602
|
+
def run_fetch_script(keys)
|
|
603
|
+
Cogworker.config.redis { |c| LuaScript.run(c, FETCH_SCRIPT, keys: keys) }
|
|
211
604
|
rescue Redis::CommandError => e
|
|
212
605
|
raise unless e.message.match?(/unknown (redis )?command/i)
|
|
213
606
|
|
|
@@ -219,7 +612,7 @@ module Cogworker
|
|
|
219
612
|
def idle_sleep
|
|
220
613
|
max = Cogworker.config.fetch_idle_max_interval
|
|
221
614
|
@idle_interval = @idle_interval ? [@idle_interval * 2, max].min : [EMPTY_POLL_INTERVAL, max].min
|
|
222
|
-
|
|
615
|
+
interruptible_sleep(@idle_interval * (0.75 + (rand * 0.25)))
|
|
223
616
|
end
|
|
224
617
|
end
|
|
225
618
|
end
|