cogworker 0.4.1 → 0.5.0

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