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.
@@ -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
- # The hot path runs this by hash (EVALSHA), not by shipping the whole
45
- # script text on every poll; see #run_fetch_script.
46
- FETCH_SCRIPT_SHA = Digest::SHA1.hexdigest(FETCH_SCRIPT)
47
-
48
- # KEYS[1] = an in-progress list; ARGV[1] = queue key prefix; ARGV[2] =
49
- # queue for a payload whose own `queue` can't be read (the Processor
50
- # then buries it as unparseable); ARGV[3] = periodic running-lock key
51
- # prefix, ARGV[4] = its queued TTL. Oldest first, each onto the end its
52
- # queue is popped from, so recovered jobs run next, in their original
53
- # order. Atomic per call: two processes recovering the same list can't
54
- # both move one job. A job from an `until_executed` periodic entry
55
- # re-takes its running lock on the way (same rule as
56
- # Periodic::RunningLock::RETAKE_FUNCTION), so the ticker can't start
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(ARGV[3] .. pjid, jid, ARGV[4])
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, KEYS[2] = the job's queue; ARGV[1] = job.
82
- GIVE_BACK_SCRIPT = <<~LUA
83
- if redis.call("LREM", KEYS[1], 1, ARGV[1]) == 1 then
84
- redis.call("RPUSH", KEYS[2], ARGV[1])
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 # can't tell (Redis not reachable yet): assume a current server
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. Used for this process's own unfinished jobs
98
- # on shutdown (`Manager#stop!`) and for dead processes' lists.
99
- def requeue_in_progress(identity)
100
- Cogworker.config.redis do |c|
101
- c.eval(REQUEUE_SCRIPT, keys: [RedisKeys.in_progress(identity)],
102
- argv: [RedisKeys::QUEUE_PREFIX, 'default', RedisKeys.periodic_running(''),
103
- Periodic::RunningLock.queued_ttl])
104
- end
105
- end
106
-
107
- # Requeues the in-progress list of every process that has gone
108
- # `config.orphan_threshold` without a heartbeat — i.e. that died
109
- # without a clean shutdown (see #dead?). A process always
110
- # writes its heartbeat before its first fetch (`Heartbeat#start!`), so
111
- # a live process's list is never mistaken for an orphan. Walks
112
- # IN_PROGRESS_IDENTITIES (each reliable-fetch process registers itself
113
- # there on every beat) rather than SCANning the keyspace; `scan: true`
114
- # (once per process, at boot) also picks up lists from before that
115
- # set existed.
116
- def recover_orphans(scan: false)
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 || !dead?(identity)
328
+ moved = candidate_identities(scan: scan, report: report).sum do |identity|
329
+ next 0 if identity == own
120
330
 
121
- requeue_in_progress(identity).tap do |moved|
122
- forget_if_empty(identity)
123
- next unless moved.positive?
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
- Cogworker.logger.warn { "requeued #{moved} job(s) left in progress by dead process #{identity}" }
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
- # Its presence key gone (no beat for Heartbeat::TTL) is not enough on
131
- # its own: a live process that just couldn't beat for a minute (a
132
- # Redis outage or failover, a long GVL-holding call) would lose its
133
- # in-progress jobs to a second, parallel run. It also has to have gone
134
- # `orphan_threshold` since its last recorded beat — compared on
135
- # Redis's clock on both sides, so host clock skew doesn't matter. No
136
- # recorded beat at all (a list from before LAST_BEAT existed) falls
137
- # back to the presence key alone.
138
- def dead?(identity)
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
- next false if c.exists?(RedisKeys.process(identity))
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
- last = c.hget(RedisKeys::LAST_BEAT, identity)
143
- last.nil? || c.time.first - last.to_f > Cogworker.config.orphan_threshold
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
- def candidate_identities(scan:)
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
- identities = c.smembers(RedisKeys::IN_PROGRESS_IDENTITIES)
150
- next identities unless scan
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
- scanned = c.scan_each(match: "#{RedisKeys::IN_PROGRESS_PREFIX}*").map do |key|
153
- key.delete_prefix(RedisKeys::IN_PROGRESS_PREFIX)
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
- sleep(TIMEOUT)
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
- c.eval(GIVE_BACK_SCRIPT, keys: [RedisKeys.in_progress(Cogworker.identity), RedisKeys.queue(work.queue)],
193
- argv: [work.raw_job])
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
- Cogworker.config.redis { |c| c.lrem(RedisKeys.in_progress(Cogworker.identity), 1, work.raw_job) }
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
- def run_fetch_script(keys)
204
- Cogworker.config.redis do |c|
205
- c.evalsha(FETCH_SCRIPT_SHA, keys: keys)
206
- rescue Redis::CommandError => e
207
- raise unless e.message.start_with?('NOSCRIPT')
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
- sleep(@idle_interval * (0.75 + (rand * 0.25)))
615
+ interruptible_sleep(@idle_interval * (0.75 + (rand * 0.25)))
223
616
  end
224
617
  end
225
618
  end