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.
@@ -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). Returns how many it moved, or -1 if the owner turned
77
- # out to be alive. Oldest first, so recovered jobs keep their order. The
78
- # "is the owner really dead?" check (see ReliableFetch.dead?) is made
79
- # here, atomically with the requeue: checked separately, an owner that
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
- local last = redis.call("HGET", KEYS[3], ARGV[5])
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(redis.call("TIME")[1])
92
- if now - tonumber(last) <= tonumber(ARGV[6]) then
93
- return -1
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
- while true do
99
- local job = redis.call("LPOP", KEYS[1])
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
- return moved
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("LREM", KEYS[1], 1, ARGV[1]) == 1 then
113
- requeue_job(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5])
114
- return 1
240
+ if not redis.call("LPOS", KEYS[1], ARGV[1]) then
241
+ return 0
115
242
  end
116
- return 0
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("LREM", KEYS[1], 1, ARGV[1]) == 1 then
125
- redis.call("ZADD", KEYS[2], ARGV[2], ARGV[3])
126
- return 1
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
- return 0
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 Redis::BaseConnectionError
141
- raise
142
- rescue StandardError
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
- def requeue_in_progress(identity, if_dead_for: nil)
152
- Cogworker.config.redis do |c|
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
- keys: [RedisKeys.in_progress(identity), RedisKeys.process(identity), RedisKeys::LAST_BEAT],
155
- argv: [*requeue_args, identity, if_dead_for.to_s])
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
- def recover_orphans(scan: false)
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
- moved = requeue_in_progress(identity, if_dead_for: Cogworker.config.orphan_threshold)
183
- next 0 if moved.negative? # alive after all: keep its registration and last beat
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
- forget_if_empty(identity)
186
- Cogworker.logger.warn { "requeued #{moved} job(s) left in progress by dead process #{identity}" } if moved.positive?
187
- moved
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 (not in `cogworker:workers:<identity>`) — left behind when
193
- # neither acknowledging nor handing a job back worked. Requeued once
194
- # they've been seen stray on two calls in a row (the caller passes
195
- # back what the previous call returned), which rules out a job caught
196
- # in the instant between being fetched and registered. Returns this
197
- # call's strays that weren't requeued yet.
198
- def reconcile(identity, previous_strays = [])
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: [RedisKeys.in_progress(identity)],
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
- def candidate_identities(scan:)
221
- Cogworker.config.redis do |c|
222
- identities = c.smembers(RedisKeys::IN_PROGRESS_IDENTITIES)
223
- next identities unless scan
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
- scanned = c.scan_each(match: "#{RedisKeys::IN_PROGRESS_PREFIX}*").map do |key|
226
- key.delete_prefix(RedisKeys::IN_PROGRESS_PREFIX)
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
- argv: [work.raw_job, *self.class.requeue_args])
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
- argv: [work.raw_job, score, payload])
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
- Cogworker.config.redis { |c| c.lrem(RedisKeys.in_progress(Cogworker.identity), 1, work.raw_job) }
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