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.
@@ -20,10 +20,14 @@ module Cogworker
20
20
  # Undoes a won claim whose push then failed, so the slot isn't lost:
21
21
  # puts `last_slot` back (only if it still is this slot — a later slot
22
22
  # may have been claimed since) and drops the per-slot NX lock.
23
- # KEYS[1] = last_slot, KEYS[2] = slot lock; ARGV[1] = slot, ARGV[2] =
24
- # previous last_slot ("" if none: then slot - 1, which still lets this
25
- # slot fire, unlike deleting the key — with catch_up off, a missing
26
- # last_slot would be re-primed to this very slot instead).
23
+ # KEYS[1] = last_slot, KEYS[2] = slot lock, KEYS[3] = running lock;
24
+ # ARGV[1] = slot, ARGV[2] = previous last_slot ("" if none: then
25
+ # slot - 1, which still lets this slot fire, unlike deleting the key —
26
+ # with catch_up off, a missing last_slot would be re-primed to this
27
+ # very slot instead), ARGV[3] = the jid the claim took the running lock
28
+ # under. Releasing that lock happens here too, in the same step: as a
29
+ # separate call made first, its failure (Redis down — the usual reason
30
+ # the push failed) skipped the rollback altogether.
27
31
  ROLLBACK_SCRIPT = <<~LUA
28
32
  if redis.call("GET", KEYS[1]) == ARGV[1] then
29
33
  if ARGV[2] == "" then
@@ -33,19 +37,36 @@ module Cogworker
33
37
  end
34
38
  end
35
39
  redis.call("DEL", KEYS[2])
40
+ -- Best-effort (an entry without until_executed has no lock; one of
41
+ -- the wrong type mustn't make an applied rollback count as failed
42
+ -- and be retried forever).
43
+ if redis.pcall("GET", KEYS[3]) == ARGV[3] then
44
+ redis.pcall("DEL", KEYS[3])
45
+ end
36
46
  return 1
37
47
  LUA
38
48
 
39
- def initialize(manager, entries, catch_up: true)
49
+ def initialize(manager, entries, catch_up: true, ready: -> { true })
40
50
  @manager = manager
41
51
  @entries = entries
42
52
  @catch_up = catch_up
53
+ @ready = ready
43
54
  @last_checked_slot = {}
44
55
  @cron_cache = {}
56
+ @pending_rollbacks = []
57
+ @failures = Hash.new(0) # pjid => consecutive failed ticks
58
+ @retry_at = {} # pjid => monotonic time it may be tried again
45
59
  end
46
60
 
61
+ # Publishing is only for restart survival and the Web UI: its failure
62
+ # (Redis down at boot, the key holding the wrong type) is logged, and
63
+ # must not take the process down with it.
47
64
  def start!
48
- publish_schedule!
65
+ begin
66
+ publish_schedule!
67
+ rescue StandardError => e
68
+ Cogworker.logger.error { "Periodic schedule not published: #{e.class}: #{e.message}" }
69
+ end
49
70
  @thread = Thread.new { run }
50
71
  end
51
72
 
@@ -82,7 +103,7 @@ module Cogworker
82
103
  def run
83
104
  until @manager.stopping?
84
105
  begin
85
- tick unless @manager.quiet?
106
+ tick unless @manager.quiet? || !@ready.call
86
107
  rescue StandardError => e
87
108
  Cogworker.logger.error { "Periodic tick failed: #{e.class}: #{e.message}" }
88
109
  end
@@ -91,15 +112,91 @@ module Cogworker
91
112
  end
92
113
 
93
114
  def tick
115
+ retry_pending_rollbacks
94
116
  now = Time.now
95
117
  @entries.each do |entry|
96
- slot = cron_for(entry).previous_time(now).to_i
97
- next if @last_checked_slot[entry.pjid] == slot
118
+ next if backing_off?(entry.pjid)
119
+
120
+ tick_entry(entry, now)
121
+ @failures.delete(entry.pjid)
122
+ rescue StandardError => e
123
+ # Per entry: one entry failing (its keys of the wrong type, its
124
+ # push failing) used to skip every entry after it, on every tick.
125
+ # Its slot isn't recorded as checked, so it's retried — after an
126
+ # exponential pause (`back_off`), not on every 5s tick, which made a
127
+ # deterministically failing entry hammer Redis and the log. Not for
128
+ # a lost connection, though: that's Redis being away, not the
129
+ # entry failing — backing off every entry on it left cron idle for
130
+ # minutes after Redis came back.
131
+ delay = e.is_a?(Redis::BaseConnectionError) ? TICK_INTERVAL : back_off(entry.pjid)
132
+ Cogworker.logger.error { "Periodic tick failed for #{entry.pjid} (next try in #{delay}s): #{e.class}: #{e.message}" }
133
+ end
134
+ end
135
+
136
+ BACKOFF_MAX = 300
137
+
138
+ def backing_off?(key)
139
+ (at = @retry_at[key]) && ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) < at
140
+ end
141
+
142
+ # 5s, 10s, 20s … up to BACKOFF_MAX; reset by the next success.
143
+ def back_off(key)
144
+ @failures[key] += 1
145
+ delay = [TICK_INTERVAL * (2**(@failures[key] - 1)), BACKOFF_MAX].min
146
+ @retry_at[key] = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + delay
147
+ delay
148
+ end
149
+
150
+ def tick_entry(entry, now)
151
+ slot = cron_for(entry).previous_time(now).to_i
152
+ return if @last_checked_slot[entry.pjid] == slot
153
+
154
+ jid = SecureRandom.hex(12)
155
+ previous = claim(entry, slot, jid) unless disabled?(entry)
156
+ enqueue(entry, slot, jid, previous) if previous
157
+ @last_checked_slot[entry.pjid] = slot
158
+ end
98
159
 
99
- jid = SecureRandom.hex(12)
100
- previous = claim(entry, slot, jid) unless disabled?(entry)
101
- enqueue(entry, slot, jid, previous) if previous
102
- @last_checked_slot[entry.pjid] = slot
160
+ # The usual reason a push fails — Redis unreachable — makes this fail
161
+ # too; it's then kept and retried at the start of every tick until it
162
+ # goes through (before that tick looks at the slot again), rather than
163
+ # just logged and the slot lost. Returns true, or the error. (A push
164
+ # that timed out *after* Redis had applied it is the one case this
165
+ # turns into a second run of the slot: indistinguishable from one that
166
+ # never arrived.) Pending
167
+ # rollbacks live in this process's memory only: one still pending when
168
+ # the process restarts is lost — that slot is skipped, and an
169
+ # `until_executed` entry stays locked until its lock's TTL.
170
+ def rollback_claim(entry, slot, previous_slot, jid)
171
+ Cogworker.config.redis do |c|
172
+ keys = [RedisKeys.periodic_last_slot(entry.pjid), RedisKeys.periodic_lock(entry.pjid, slot),
173
+ RedisKeys.periodic_running(entry.pjid)]
174
+ LuaScript.run(c, ROLLBACK_SCRIPT, keys: keys, argv: [slot, previous_slot, jid])
175
+ end
176
+ true
177
+ rescue StandardError => e
178
+ Cogworker.logger.error { "periodic #{entry.pjid}: couldn't roll back slot #{slot} (will retry): #{e.class}: #{e.message}" }
179
+ @pending_rollbacks << [entry, slot, previous_slot, jid]
180
+ e # (truthy, but not `true`: the caller tells a lost connection from a failing rollback)
181
+ end
182
+
183
+ # With the same backoff as a failing entry (keyed by the rollback): a
184
+ # rollback that keeps failing is retried less and less often, but
185
+ # always before that entry's slot is looked at again.
186
+ def retry_pending_rollbacks
187
+ pending = @pending_rollbacks
188
+ @pending_rollbacks = []
189
+ pending.each do |args|
190
+ key = "rollback:#{args[0].pjid}:#{args[1]}"
191
+ next @pending_rollbacks << args if backing_off?(key)
192
+
193
+ result = rollback_claim(*args)
194
+ if result == true
195
+ @failures.delete(key)
196
+ @retry_at.delete(key)
197
+ elsif !result.is_a?(Redis::BaseConnectionError)
198
+ back_off(key) # only a rollback failing on its own backs off; see #tick
199
+ end
103
200
  end
104
201
  end
105
202
 
@@ -114,17 +211,18 @@ module Cogworker
114
211
  # persisted last_slot) still advances either way, exactly as it
115
212
  # already did before this entry ever had a disabled state — it only
116
213
  # stops this same tick loop from re-evaluating the same slot twice.
117
- def rollback_claim(entry, slot, previous_slot)
118
- Cogworker.config.redis do |c|
119
- keys = [RedisKeys.periodic_last_slot(entry.pjid), RedisKeys.periodic_lock(entry.pjid, slot)]
120
- c.eval(ROLLBACK_SCRIPT, keys: keys, argv: [slot, previous_slot])
121
- end
122
- rescue StandardError => e
123
- Cogworker.logger.error { "periodic #{entry.pjid}: couldn't roll back slot #{slot}: #{e.class}: #{e.message}" }
124
- end
125
-
214
+ #
215
+ # A disabled-set that can't be read (the key holding the wrong type)
216
+ # counts as "not disabled", logged: it used to fail every entry's tick,
217
+ # stopping all cron. Losing a Disable is the lesser evil. A dropped
218
+ # connection still raises (the tick is then retried as a whole).
126
219
  def disabled?(entry)
127
220
  Cogworker.config.redis { |c| c.sismember(RedisKeys::PERIODIC_DISABLED, entry.pjid) }
221
+ rescue Redis::BaseConnectionError
222
+ raise
223
+ rescue StandardError => e
224
+ Cogworker.logger.error { "Periodic: can't read #{RedisKeys::PERIODIC_DISABLED} (#{e.class}: #{e.message}); treating #{entry.pjid} as enabled" }
225
+ false
128
226
  end
129
227
 
130
228
  def cron_for(entry)
@@ -137,7 +235,7 @@ module Cogworker
137
235
  return nil if !@catch_up && priming_first_slot?(entry, slot)
138
236
 
139
237
  result = Cogworker.config.redis do |c|
140
- c.eval(CLAIM_SCRIPT,
238
+ LuaScript.run(c, CLAIM_SCRIPT,
141
239
  keys: [RedisKeys.periodic_running(entry.pjid), RedisKeys.periodic_last_slot(entry.pjid),
142
240
  RedisKeys.periodic_lock(entry.pjid, slot)],
143
241
  argv: [slot, entry.unique.to_s, LOCK_TTL, jid, RunningLock.queued_ttl])
@@ -183,8 +281,7 @@ module Cogworker
183
281
  pushed = begin
184
282
  Client.push(job)
185
283
  rescue StandardError
186
- RunningLock.release(entry.pjid, jid) if entry.until_executed?
187
- rollback_claim(entry, slot, previous_slot)
284
+ rollback_claim(entry, slot, previous_slot, jid)
188
285
  raise
189
286
  end
190
287
  return unless entry.until_executed?
@@ -6,9 +6,26 @@ require 'securerandom'
6
6
  module Cogworker
7
7
  # One thread of a Manager's pool: fetch -> register in-flight -> run the
8
8
  # server middleware chain around perform -> stats/retry -> deregister.
9
+ #
10
+ # What happens to the fetched job afterwards depends on how far it got —
11
+ # the one rule being that a job is never re-run because of a failure that
12
+ # happened *after* it ran:
13
+ # - failed before `perform` (registering it in `cogworker:workers`):
14
+ # `give_back` — straight back on its queue, it hasn't run yet;
15
+ # - ran (succeeded, or failed and was recorded in retry/dead): acknowledge.
16
+ # Everything after `perform` other than that one retry/dead write —
17
+ # stats, throughput, deregistering, lock extension, the attempts log —
18
+ # is best-effort: logged, never raised;
19
+ # - ran and failed, but the retry/dead write itself failed: `interrupt` —
20
+ # into `retry` after INTERRUPT_DELAY, counting `interrupted_count`, and
21
+ # into `dead` once that passes MAX_INTERRUPTS. Never straight back on
22
+ # the queue: a write that fails deterministically used to make that
23
+ # loop forever, re-running the job every second.
9
24
  class Processor
10
25
  ERROR_BACKOFF = 1 # seconds
11
26
  ACK_ATTEMPTS = 3
27
+ MAX_INTERRUPTS = 3
28
+ INTERRUPT_DELAY = 30 # seconds, times the interruption count
12
29
 
13
30
  attr_reader :thread, :tid
14
31
 
@@ -69,33 +86,104 @@ module Cogworker
69
86
  return fetcher.give_back(work) if @manager.stopping?
70
87
 
71
88
  @manager.processor_busy!
89
+ @manager.job_started(work.raw_job)
72
90
  @busy = true
73
- finished = false
91
+ @ran = nil
92
+ disposition = nil
74
93
  begin
75
- execute(work)
76
- finished = true
94
+ disposition = execute(work)
77
95
  ensure
78
96
  @busy = false
79
97
  @manager.processor_idle!
80
- finished ? acknowledge(work) : give_back(work)
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)
81
105
  end
82
106
  end
83
107
 
84
- # `execute` raising means bookkeeping failed (Redis, mid-way through
85
- # registering the job or routing its failure to retry/dead) — not the
86
- # job itself, whose own errors `execute` always handles. Acknowledging
87
- # here would drop a job that never made it to retry/dead; instead it
88
- # goes straight back on its queue (it may run again: at-least-once). If
89
- # even that fails, it simply stays on the in-progress list, which
90
- # `Manager#stop!`/orphan recovery requeue later.
108
+ def settle(work, disposition)
109
+ case disposition
110
+ when :acknowledge then acknowledge(work)
111
+ when :give_back then give_back(work)
112
+ else interrupt(work, *disposition.drop(1))
113
+ end
114
+ end
115
+
116
+ # Only for a job that hasn't run yet (see the class comment). If even
117
+ # this fails, it stays on the in-progress list, which `Manager#stop!` /
118
+ # orphan recovery / `ReliableFetch.reconcile` requeue later.
91
119
  def give_back(work)
92
120
  fetcher.give_back(work)
93
121
  rescue StandardError => e
94
122
  Cogworker.logger.error do
95
- "couldn't hand back jid=#{jid_of(work)} after a failed run (left in progress): #{e.class}: #{e.message}"
123
+ "couldn't hand back jid=#{jid_of(work)} (left in progress): #{e.class}: #{e.message}"
124
+ end
125
+ end
126
+
127
+ # The job ran and failed, but recording that in retry/dead failed. Built
128
+ # from the original payload (always serializable — it came from JSON)
129
+ # plus the failure fields, never from the in-memory job hash: whatever
130
+ # made that one fail to serialize must not do it again here.
131
+ def interrupt(work, job, error)
132
+ payload = JSON.parse(work.raw_job)
133
+ count = payload['interrupted_count'].to_i + 1
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,
138
+ 'error_class' => error.class.name, 'error_message' => JobUtil.error_message(error),
139
+ 'failed_at' => Time.now.to_f)
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
145
+ [RedisKeys::DEAD, Time.now.to_f]
146
+ else
147
+ [RedisKeys::RETRY, Time.now.to_f + delay]
148
+ end
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
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}" }
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)
173
+ Cogworker.logger.error do
174
+ "couldn't record or interrupt jid=#{jid_of(work)} (left in progress): #{e.class}: #{e.message}"
96
175
  end
97
176
  end
98
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
+
99
187
  # A failed ack leaves an already-finished job on this process's own
100
188
  # in-progress list, where `Manager#stop!` would later requeue it — a
101
189
  # silent second run. Retried briefly, then logged with the jid so that
@@ -110,9 +198,12 @@ module Cogworker
110
198
  sleep(0.1 * attempts)
111
199
  retry
112
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)
113
204
  Cogworker.logger.error do
114
205
  "couldn't acknowledge finished job jid=#{jid_of(work)} after #{attempts} attempts " \
115
- "(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}"
116
207
  end
117
208
  end
118
209
  end
@@ -123,53 +214,86 @@ module Cogworker
123
214
  '?'
124
215
  end
125
216
 
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.
126
220
  def execute(work)
127
221
  job = parse_job(work)
128
- return unless job
129
-
130
- register_in_workers(work.queue, job)
222
+ return :acknowledge unless job
223
+
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) }
131
230
  Cogworker.logger.info { "start: #{job['class']} jid=#{job['jid']}" }
132
-
133
231
  begin
134
- # `build_worker` itself can raise (most commonly `NameError`, e.g. a
135
- # class the caller registered/enqueued but never actually defined —
136
- # deliberately still routed through the *same* middleware chain
137
- # below rather than caught here directly: `History::Middleware`/
138
- # `Status::ServerMiddleware` (and any custom server middleware)
139
- # only ever see a job by wrapping this `chain.invoke` call, so a
140
- # resolution failure caught out here, before `invoke` ever runs,
141
- # would never reach them — this job would fail/retry/die with no
142
- # History entry and no status update at all, silently. Catching it
143
- # here and re-raising it as the chain's own "final block" gives
144
- # every middleware the same crack at observing this failure as any
145
- # perform-time one, with `worker` as `nil` in that case (every
146
- # built-in server middleware ignores the `worker` arg entirely
147
- # already; a custom one that needs a real instance simply can't act
148
- # on this failure either way — there's no worker to act on).
149
- resolution_error = nil
150
- worker = begin
151
- build_worker(job)
152
- rescue Exception => e # rubocop:disable Lint/RescueException
153
- resolution_error = e
154
- nil
155
- end
232
+ error = run_job(work, job)
233
+ @ran = error ? [:interrupt, job, error] : :acknowledge
234
+ error ? finish_failure(job, error) : finish_success(job)
235
+ ensure
236
+ best_effort('deregister') { deregister_from_workers }
237
+ end
238
+ end
156
239
 
157
- Cogworker.config.server_chain.invoke(worker, job, work.queue) do
158
- raise resolution_error if resolution_error
240
+ # The job's own outcome: nil, or what it (or its middleware) raised.
241
+ def run_job(work, job)
242
+ # `build_worker` itself can raise (most commonly `NameError`, e.g. a
243
+ # class the caller registered/enqueued but never actually defined —
244
+ # deliberately still routed through the *same* middleware chain
245
+ # below rather than caught here directly: `History::Middleware`/
246
+ # `Status::ServerMiddleware` (and any custom server middleware)
247
+ # only ever see a job by wrapping this `chain.invoke` call, so a
248
+ # resolution failure caught out here, before `invoke` ever runs,
249
+ # would never reach them — this job would fail/retry/die with no
250
+ # History entry and no status update at all, silently. Catching it
251
+ # here and re-raising it as the chain's own "final block" gives
252
+ # every middleware the same crack at observing this failure as any
253
+ # perform-time one, with `worker` as `nil` in that case (every
254
+ # built-in server middleware ignores the `worker` arg entirely
255
+ # already; a custom one that needs a real instance simply can't act
256
+ # on this failure either way — there's no worker to act on).
257
+ resolution_error = nil
258
+ worker = begin
259
+ build_worker(job)
260
+ rescue Exception => e # rubocop:disable Lint/RescueException
261
+ resolution_error = e
262
+ nil
263
+ end
159
264
 
160
- worker.perform(*job['args'])
161
- end
265
+ Cogworker.config.server_chain.invoke(worker, job, work.queue) do
266
+ raise resolution_error if resolution_error
267
+
268
+ worker.perform(*job['args'])
269
+ end
270
+ nil
271
+ rescue Exception => e # rubocop:disable Lint/RescueException
272
+ e
273
+ end
274
+
275
+ def finish_success(job)
276
+ best_effort('stats') do
162
277
  Cogworker.config.redis { |c| c.incr(RedisKeys::STATS_PROCESSED) }
163
278
  Throughput.record('processed')
164
- Cogworker.logger.info { "done: #{job['class']} jid=#{job['jid']}" }
165
- rescue Exception => e # rubocop:disable Lint/RescueException
279
+ end
280
+ Cogworker.logger.info { "done: #{job['class']} jid=#{job['jid']}" }
281
+ :acknowledge
282
+ end
283
+
284
+ def finish_failure(job, error)
285
+ best_effort('stats') do
166
286
  Cogworker.config.redis { |c| c.incr(RedisKeys::STATS_FAILED) }
167
287
  Throughput.record('failed')
168
- route_failure(job, e)
169
- Cogworker.logger.warn { "fail: #{job['class']} jid=#{job['jid']}: #{e.class}: #{e&.message}" }
170
288
  end
171
- ensure
172
- deregister_from_workers
289
+ Cogworker.logger.warn { "fail: #{job['class']} jid=#{job['jid']}: #{error.class}: #{JobUtil.error_message(error)}" }
290
+ route_failure(job, error) ? :acknowledge : [:interrupt, job, error]
291
+ end
292
+
293
+ def best_effort(what)
294
+ yield
295
+ rescue StandardError => e
296
+ Cogworker.logger.error { "#{what} failed (ignored): #{e.class}: #{e.message}" }
173
297
  end
174
298
 
175
299
  # A payload that isn't a JSON object goes straight to `dead`
@@ -199,7 +323,7 @@ module Cogworker
199
323
  # Built on first use, not in the constructor (see Manager#fetch_class),
200
324
  # and rebuilt after a fall-back to BasicFetch.
201
325
  def fetcher
202
- @fetcher ||= @manager.fetch_class.new(@manager.queues)
326
+ @fetcher ||= @manager.fetch_class.new(@manager.queues, stopping: -> { @manager.stopping? })
203
327
  end
204
328
 
205
329
  def build_worker(job)
@@ -209,24 +333,35 @@ module Cogworker
209
333
  worker
210
334
  end
211
335
 
336
+ # True once the failure is recorded in retry/dead — the one write that
337
+ # must happen; everything after it is best-effort. False if that write
338
+ # failed (`finish_failure` then interrupts the job instead).
212
339
  def route_failure(job, error)
213
340
  job['error_class'] = error.class.name
214
- job['error_message'] = error.message.to_s[0, 10_000]
341
+ job['error_message'] = JobUtil.error_message(error)
215
342
  job['failed_at'] ||= Time.now.to_f
216
343
 
217
344
  max_retries = JobUtil.max_retries(job)
218
345
  new_count = job['retry_count'].to_i + 1
219
346
  job['retry_count'] = new_count
347
+ retrying = new_count <= max_retries
348
+ delay = retry_delay(new_count) if retrying
349
+
350
+ begin
351
+ payload = JSON.generate(job)
352
+ Cogworker.config.redis do |c|
353
+ retrying ? c.zadd(RedisKeys::RETRY, Time.now.to_f + delay, payload) : c.zadd(RedisKeys::DEAD, Time.now.to_f, payload)
354
+ end
355
+ rescue StandardError => e
356
+ Cogworker.logger.error { "couldn't record failure of jid=#{job['jid']}: #{e.class}: #{e.message}" }
357
+ return false
358
+ end
220
359
 
221
- if new_count <= max_retries
222
- delay = retry_delay(new_count)
223
- Cogworker.config.redis { |c| c.zadd(RedisKeys::RETRY, Time.now.to_f + delay, JSON.generate(job)) }
224
- extend_locks_for_retry(job, delay)
225
- Attempts.record(job['jid'], attempt: new_count, error: error, outcome: 'retrying')
226
- else
227
- Cogworker.config.redis { |c| c.zadd(RedisKeys::DEAD, Time.now.to_f, JSON.generate(job)) }
228
- Attempts.record(job['jid'], attempt: new_count, error: error, outcome: 'dead')
360
+ best_effort('lock extension') { extend_locks_for_retry(job, delay) } if retrying
361
+ best_effort('attempts log') do
362
+ Attempts.record(job['jid'], attempt: new_count, error: error, outcome: retrying ? 'retrying' : 'dead')
229
363
  end
364
+ true
230
365
  end
231
366
 
232
367
  # A job waiting out its retry backoff still holds its `until_executed`
@@ -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'