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.
@@ -23,14 +23,25 @@ module Cogworker
23
23
  begin
24
24
  yield
25
25
  rescue Exception => e # rubocop:disable Lint/RescueException
26
- RunningLock.release(pjid, job['jid']) if JobUtil.terminal_failure?(job)
26
+ release(pjid, job['jid']) if JobUtil.terminal_failure?(job)
27
27
  raise e
28
28
  end
29
- RunningLock.release(pjid, job['jid'])
29
+ release(pjid, job['jid'])
30
30
  end
31
31
 
32
32
  private
33
33
 
34
+ # Best-effort — a release that fails (Redis away) never turns the
35
+ # finished run into a failure — and retried later (DeferredReleases)
36
+ # rather than left to the lock's TTL.
37
+ def release(pjid, jid)
38
+ released = BestEffort.call('Periodic lock') do
39
+ RunningLock.release(pjid, jid)
40
+ true
41
+ end
42
+ DeferredReleases.add(RedisKeys.periodic_running(pjid), jid) unless released
43
+ end
44
+
34
45
  # Best-effort: failing here must not stop the job from running (it
35
46
  # would be routed to retry/dead as if it had failed). If it does fail,
36
47
  # the lock just keeps its longer queued TTL until this process's next
@@ -61,6 +61,18 @@ module Cogworker
61
61
  OwnedKey.expire(RedisKeys.periodic_running(pjid), jid, ttl, conn)
62
62
  end
63
63
 
64
+ RETAKE_SCRIPT = RETAKE_FUNCTION + <<~LUA
65
+ retake_running_lock(KEYS[1], ARGV[1], ARGV[2])
66
+ return 1
67
+ LUA
68
+
69
+ # Extends the lock if still `jid`'s, takes it if it's gone, leaves it
70
+ # if another run holds it — for a run still going whose lock may have
71
+ # lapsed (Redis away longer than `active_ttl`).
72
+ def retake(pjid, jid, ttl, conn)
73
+ LuaScript.run(conn, RETAKE_SCRIPT, keys: [RedisKeys.periodic_running(pjid)], argv: [jid, ttl.to_f.ceil])
74
+ end
75
+
64
76
  def release(pjid, jid, conn = nil)
65
77
  OwnedKey.delete(RedisKeys.periodic_running(pjid), jid, conn)
66
78
  end
@@ -20,12 +20,16 @@ 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
- if redis.call("GET", KEYS[1]) == ARGV[1] then
32
+ if redis.pcall("GET", KEYS[1]) == ARGV[1] then
29
33
  if ARGV[2] == "" then
30
34
  redis.call("SET", KEYS[1], tostring(tonumber(ARGV[1]) - 1))
31
35
  else
@@ -33,6 +37,12 @@ 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
 
@@ -44,10 +54,20 @@ module Cogworker
44
54
  @last_checked_slot = {}
45
55
  @cron_cache = {}
46
56
  @pending_rollbacks = []
57
+ @blocked_since = {} # pjid => [slot, monotonic time it was first found blocked]
58
+ @failures = Hash.new(0) # pjid => consecutive failed ticks
59
+ @retry_at = {} # pjid => monotonic time it may be tried again
47
60
  end
48
61
 
62
+ # Publishing is only for restart survival and the Web UI: its failure
63
+ # (Redis down at boot, the key holding the wrong type) is logged, and
64
+ # must not take the process down with it.
49
65
  def start!
50
- publish_schedule!
66
+ begin
67
+ publish_schedule!
68
+ rescue StandardError => e
69
+ Cogworker.logger.error { "Periodic schedule not published: #{e.class}: #{e.message}" }
70
+ end
51
71
  @thread = Thread.new { run }
52
72
  end
53
73
 
@@ -94,72 +114,161 @@ module Cogworker
94
114
 
95
115
  def tick
96
116
  retry_pending_rollbacks
117
+ # This process's own delayed lock releases first (see BLOCKED_GRACE).
118
+ BestEffort.call('Deferred lock releases') { Cogworker.config.redis { |c| DeferredReleases.retry_all(c) } }
97
119
  now = Time.now
98
120
  @entries.each do |entry|
99
- slot = cron_for(entry).previous_time(now).to_i
100
- next if @last_checked_slot[entry.pjid] == slot
121
+ next if backing_off?(entry.pjid)
122
+
123
+ next if tick_entry(entry, now) == :skipped # (no Redis call made: proves nothing)
101
124
 
102
- jid = SecureRandom.hex(12)
103
- previous = claim(entry, slot, jid) unless disabled?(entry)
104
- enqueue(entry, slot, jid, previous) if previous
105
- @last_checked_slot[entry.pjid] = slot
125
+ @failures.delete(entry.pjid)
126
+ RedisErrors.recovered('Periodic tick')
127
+ rescue StandardError => e
128
+ # Per entry: one entry failing (its keys of the wrong type, its
129
+ # push failing) used to skip every entry after it, on every tick.
130
+ # Its slot isn't recorded as checked, so it's retried — after an
131
+ # exponential pause (`back_off`), not on every 5s tick, which made a
132
+ # deterministically failing entry hammer Redis and the log. Not for
133
+ # a lost connection, though: that's Redis being away, not the
134
+ # entry failing — backing off every entry on it left cron idle for
135
+ # minutes after Redis came back.
136
+ delay = RedisErrors.unavailable?(e) ? TICK_INTERVAL : back_off(entry.pjid)
137
+ RedisErrors.report('Periodic tick', e) { "#{entry.pjid} failed (next try in #{delay}s)" }
106
138
  end
107
139
  end
108
140
 
109
- # Web UI "Disable" (`Routes::Schedules`) — skips the claim/enqueue
110
- # step entirely, short-circuiting before `claim` even runs, so
111
- # neither `periodic:last_slot:<pjid>` nor the per-slot lock advance
112
- # while disabled: the Web UI's own "LastRun" column keeps showing the
113
- # last time it *actually* ran, not a due-but-skipped slot, and
114
- # re-enabling doesn't trigger a catch-up burst for every slot that
115
- # was silently skipped in between. `@last_checked_slot` (this one
116
- # Ticker instance's own in-memory dedup, unrelated to the Redis-
117
- # persisted last_slot) still advances either way, exactly as it
118
- # already did before this entry ever had a disabled state — it only
119
- # stops this same tick loop from re-evaluating the same slot twice.
141
+ BACKOFF_MAX = 300
142
+
143
+ def backing_off?(key)
144
+ (at = @retry_at[key]) && ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) < at
145
+ end
146
+
147
+ # 5s, 10s, 20s … up to BACKOFF_MAX; reset by the next success.
148
+ def back_off(key)
149
+ @failures[key] += 1
150
+ delay = [TICK_INTERVAL * (2**(@failures[key] - 1)), BACKOFF_MAX].min
151
+ @retry_at[key] = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + delay
152
+ delay
153
+ end
154
+
155
+ def tick_entry(entry, now)
156
+ slot = cron_for(entry).previous_time(now).to_i
157
+ return :skipped if @last_checked_slot[entry.pjid] == slot
158
+
159
+ jid = SecureRandom.hex(12)
160
+ previous = claim(entry, slot, jid) unless disabled?(entry)
161
+ return if previous == :blocked && still_blocked_in_grace?(entry.pjid, slot)
162
+
163
+ enqueue(entry, slot, jid, previous) if previous && previous != :blocked
164
+ @last_checked_slot[entry.pjid] = slot
165
+ @blocked_since.delete(entry.pjid)
166
+ end
167
+
168
+ # A slot blocked by the entry's running lock is retried for
169
+ # BLOCKED_GRACE before it counts as skipped. After an outage, the lock
170
+ # may still be held only because its finished run's release was
171
+ # deferred (DeferredReleases — retried on the next heartbeat, in
172
+ # whichever process ran it); marking the slot checked straight away
173
+ # lost it, in every process, until the entry's next slot — an hour,
174
+ # for an hourly entry. A run genuinely still going keeps the lock past
175
+ # the grace, and its slot is skipped as `until_executed` intends.
176
+ BLOCKED_GRACE = 3 * Heartbeat::INTERVAL
177
+
178
+ def still_blocked_in_grace?(pjid, slot)
179
+ now = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
180
+ since_slot, since = @blocked_since[pjid]
181
+ @blocked_since[pjid] = [slot, since = now] unless since_slot == slot
182
+ now - since < BLOCKED_GRACE
183
+ end
184
+
120
185
  # The usual reason a push fails — Redis unreachable — makes this fail
121
186
  # too; it's then kept and retried at the start of every tick until it
122
187
  # goes through (before that tick looks at the slot again), rather than
123
- # just logged and the slot lost. (A push that timed out *after* Redis
124
- # had applied it is the one case this turns into a second run of the
125
- # slot: indistinguishable from one that never arrived.)
126
- def rollback_claim(entry, slot, previous_slot)
188
+ # just logged and the slot lost. Returns true, or the error. (A push
189
+ # that timed out *after* Redis had applied it is the one case this
190
+ # turns into a second run of the slot: indistinguishable from one that
191
+ # never arrived.) Pending
192
+ # rollbacks live in this process's memory only: one still pending when
193
+ # the process restarts is lost — that slot is skipped, and an
194
+ # `until_executed` entry stays locked until its lock's TTL.
195
+ def rollback_claim(entry, slot, previous_slot, jid)
127
196
  Cogworker.config.redis do |c|
128
- keys = [RedisKeys.periodic_last_slot(entry.pjid), RedisKeys.periodic_lock(entry.pjid, slot)]
129
- LuaScript.run(c, ROLLBACK_SCRIPT, keys: keys, argv: [slot, previous_slot])
197
+ keys = [RedisKeys.periodic_last_slot(entry.pjid), RedisKeys.periodic_lock(entry.pjid, slot),
198
+ RedisKeys.periodic_running(entry.pjid)]
199
+ LuaScript.run(c, ROLLBACK_SCRIPT, keys: keys, argv: [slot, previous_slot, jid])
130
200
  end
131
201
  true
132
202
  rescue StandardError => e
133
- Cogworker.logger.error { "periodic #{entry.pjid}: couldn't roll back slot #{slot} (will retry): #{e.class}: #{e.message}" }
134
- @pending_rollbacks << [entry, slot, previous_slot]
135
- false
203
+ RedisErrors.report('Periodic rollback', e) { "#{entry.pjid}: couldn't roll back slot #{slot} (will retry)" }
204
+ @pending_rollbacks << [entry, slot, previous_slot, jid]
205
+ e # (truthy, but not `true`: the caller tells a lost connection from a failing rollback)
136
206
  end
137
207
 
208
+ # With the same backoff as a failing entry (keyed by the rollback): a
209
+ # rollback that keeps failing is retried less and less often, but
210
+ # always before that entry's slot is looked at again.
138
211
  def retry_pending_rollbacks
139
212
  pending = @pending_rollbacks
140
213
  @pending_rollbacks = []
141
- pending.each { |args| rollback_claim(*args) }
214
+ pending.each do |args|
215
+ key = "rollback:#{args[0].pjid}:#{args[1]}"
216
+ next @pending_rollbacks << args if backing_off?(key)
217
+
218
+ result = rollback_claim(*args)
219
+ if result == true
220
+ @failures.delete(key)
221
+ @retry_at.delete(key)
222
+ RedisErrors.recovered('Periodic rollback')
223
+ elsif !RedisErrors.unavailable?(result)
224
+ back_off(key) # only a rollback failing on its own backs off; see #tick
225
+ end
226
+ end
142
227
  end
143
228
 
229
+ # Web UI "Disable" (`Routes::Schedules`) — skips the claim/enqueue
230
+ # step entirely, short-circuiting before `claim` even runs, so
231
+ # neither `periodic:last_slot:<pjid>` nor the per-slot lock advance
232
+ # while disabled: the Web UI's own "LastRun" column keeps showing the
233
+ # last time it *actually* ran, not a due-but-skipped slot, and
234
+ # re-enabling doesn't trigger a catch-up burst for every slot that
235
+ # was silently skipped in between. `@last_checked_slot` (this one
236
+ # Ticker instance's own in-memory dedup, unrelated to the Redis-
237
+ # persisted last_slot) still advances either way, exactly as it
238
+ # already did before this entry ever had a disabled state — it only
239
+ # stops this same tick loop from re-evaluating the same slot twice.
240
+ #
241
+ # A disabled-set that can't be read (the key holding the wrong type)
242
+ # counts as "not disabled", logged: it used to fail every entry's tick,
243
+ # stopping all cron. Losing a Disable is the lesser evil. A dropped
244
+ # connection still raises (the tick is then retried as a whole).
144
245
  def disabled?(entry)
145
246
  Cogworker.config.redis { |c| c.sismember(RedisKeys::PERIODIC_DISABLED, entry.pjid) }
247
+ rescue StandardError => e
248
+ raise if RedisErrors.unavailable?(e)
249
+
250
+ Cogworker.logger.error { "Periodic: can't read #{RedisKeys::PERIODIC_DISABLED} (#{e.class}: #{e.message}); treating #{entry.pjid} as enabled" }
251
+ false
146
252
  end
147
253
 
148
254
  def cron_for(entry)
149
255
  @cron_cache[entry.pjid] ||= Fugit::Cron.parse(entry.cron)
150
256
  end
151
257
 
152
- # nil if this process didn't win the slot; otherwise the previous
153
- # `last_slot` value ("" if none), for #rollback_claim.
258
+ # nil if this process didn't win the slot, :blocked if the entry's
259
+ # running lock stopped it; otherwise the previous `last_slot` value
260
+ # ("" if none), for #rollback_claim.
154
261
  def claim(entry, slot, jid)
155
262
  return nil if !@catch_up && priming_first_slot?(entry, slot)
156
263
 
157
264
  result = Cogworker.config.redis do |c|
158
265
  LuaScript.run(c, CLAIM_SCRIPT,
159
- keys: [RedisKeys.periodic_running(entry.pjid), RedisKeys.periodic_last_slot(entry.pjid),
160
- RedisKeys.periodic_lock(entry.pjid, slot)],
161
- argv: [slot, entry.unique.to_s, LOCK_TTL, jid, RunningLock.queued_ttl])
266
+ keys: [RedisKeys.periodic_running(entry.pjid), RedisKeys.periodic_last_slot(entry.pjid),
267
+ RedisKeys.periodic_lock(entry.pjid, slot)],
268
+ argv: [slot, entry.unique.to_s, LOCK_TTL, jid, RunningLock.queued_ttl])
162
269
  end
270
+ return :blocked if result == 2
271
+
163
272
  result.is_a?(Array) ? result[1].to_s : nil
164
273
  end
165
274
 
@@ -201,8 +310,7 @@ module Cogworker
201
310
  pushed = begin
202
311
  Client.push(job)
203
312
  rescue StandardError
204
- RunningLock.release(entry.pjid, jid) if entry.until_executed?
205
- rollback_claim(entry, slot, previous_slot)
313
+ rollback_claim(entry, slot, previous_slot, jid)
206
314
  raise
207
315
  end
208
316
  return unless entry.until_executed?
@@ -3,6 +3,9 @@
3
3
  require 'json'
4
4
 
5
5
  module Cogworker
6
+ # Every live worker process, as published by its Heartbeat — one
7
+ # Cogworker::Process per `cogworker:processes` member whose presence key
8
+ # still exists (stale members are pruned while iterating).
6
9
  class ProcessSet
7
10
  include Enumerable
8
11
 
@@ -56,9 +56,9 @@ module Cogworker
56
56
  def run
57
57
  until @manager.stopping?
58
58
  begin
59
- process_one
59
+ RedisErrors.recovered('Processor') unless process_one == :idle
60
60
  rescue StandardError => e
61
- Cogworker.logger.error { "Processor error: #{e.class}: #{e.message}" }
61
+ RedisErrors.report('Processor', e)
62
62
  sleep(ERROR_BACKOFF)
63
63
  end
64
64
  end
@@ -67,7 +67,7 @@ module Cogworker
67
67
  def process_one
68
68
  if @manager.quiet?
69
69
  sleep(0.5)
70
- return
70
+ return :idle # (no Redis call made)
71
71
  end
72
72
 
73
73
  work = begin
@@ -86,14 +86,24 @@ 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
+ @performed = false
93
+ disposition = nil
91
94
  begin
92
95
  disposition = execute(work)
93
96
  ensure
94
97
  @busy = false
98
+ @manager.job_performed(work.raw_job) unless @performed
95
99
  @manager.processor_idle!
96
- settle(work, disposition)
100
+ # `execute` raising leaves `disposition` unset: before the job ran
101
+ # that's a hand-back; once it ran, whatever its outcome dictates
102
+ # (`@ran`, set the moment `run_job` returns) — so an unexpected
103
+ # exception later in the bookkeeping (a bug, a misbehaving logger)
104
+ # can't re-queue a job that already ran.
105
+ settle(work, disposition || @ran || :give_back)
106
+ @manager.job_finished(work.raw_job)
97
107
  end
98
108
  end
99
109
 
@@ -123,25 +133,65 @@ module Cogworker
123
133
  def interrupt(work, job, error)
124
134
  payload = JSON.parse(work.raw_job)
125
135
  count = payload['interrupted_count'].to_i + 1
126
- payload.merge!('interrupted_count' => count, 'retry_count' => job['retry_count'],
136
+ # This attempt's number, from the payload as fetched — not from the
137
+ # in-memory job, whose count `route_failure` may not have got to.
138
+ attempt = payload['retry_count'].to_i + 1
139
+ payload.merge!('interrupted_count' => count, 'retry_count' => attempt,
127
140
  'error_class' => error.class.name, 'error_message' => JobUtil.error_message(error),
128
141
  'failed_at' => Time.now.to_f)
129
- set, score = if count > MAX_INTERRUPTS
142
+ # No retries left (`retry: false`/`0`, or the last one used up) means
143
+ # no extra attempt either — straight to dead, like any terminal failure.
144
+ out_of_retries = attempt > JobUtil.max_retries(payload)
145
+ delay = INTERRUPT_DELAY * count
146
+ set, score = if out_of_retries || count > MAX_INTERRUPTS
130
147
  [RedisKeys::DEAD, Time.now.to_f]
131
148
  else
132
- [RedisKeys::RETRY, Time.now.to_f + (INTERRUPT_DELAY * count)]
149
+ [RedisKeys::RETRY, Time.now.to_f + delay]
133
150
  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})"
151
+ encoded = JSON.generate(payload)
152
+ begin
153
+ fetcher.interrupt(work, set, score, encoded)
154
+ rescue StandardError
155
+ # Remembered with its payload: this process's reconcile (and its
156
+ # shutdown) keep trying to file it — never re-run it as a stray.
157
+ @manager.settle_later(work.raw_job, [set, score, encoded]) if fetcher.is_a?(ReliableFetch)
158
+ raise
138
159
  end
160
+ if set == RedisKeys::RETRY
161
+ best_effort('lock extension') { extend_locks_for_retry(job, delay) }
162
+ else
163
+ best_effort('lock release') { release_locks(payload) }
164
+ end
165
+ reason = if set == RedisKeys::RETRY
166
+ "retrying in #{delay}s (interruption #{count} of at most #{MAX_INTERRUPTS})"
167
+ elsif out_of_retries
168
+ 'moved to dead: no retries left'
169
+ else
170
+ "moved to dead: interrupted more than #{MAX_INTERRUPTS} times"
171
+ end
172
+ Cogworker.logger.error { "couldn't record the failure of jid=#{payload['jid']} (#{error.class}); #{reason}" }
139
173
  rescue StandardError => e
174
+ # Whatever failed (even before anything was written), remember the job
175
+ # with the simplest possible dead entry, so reconcile files it — not
176
+ # leave it a stray to be re-run. (Already remembered if it got as far
177
+ # as the write.)
178
+ remember_for_dead(work, error) if fetcher.is_a?(ReliableFetch) && !@manager.pending_settlements.key?(work.raw_job)
140
179
  Cogworker.logger.error do
141
180
  "couldn't record or interrupt jid=#{jid_of(work)} (left in progress): #{e.class}: #{e.message}"
142
181
  end
143
182
  end
144
183
 
184
+ def remember_for_dead(work, error)
185
+ # The original job's own fields where readable (so filing it releases
186
+ # its locks); just enough to show in Dead otherwise.
187
+ base = ReliableFetch.parse_hash(work.raw_job) || { 'raw_payload' => JobUtil.safe_string(work.raw_job, 100_000) }
188
+ entry = JSON.generate(base.merge('jid' => jid_of(work), 'error_class' => error.class.name.to_s,
189
+ 'error_message' => JobUtil.error_message(error), 'failed_at' => Time.now.to_f))
190
+ @manager.settle_later(work.raw_job, [RedisKeys::DEAD, Time.now.to_f, entry])
191
+ rescue StandardError
192
+ nil
193
+ end
194
+
145
195
  # A failed ack leaves an already-finished job on this process's own
146
196
  # in-progress list, where `Manager#stop!` would later requeue it — a
147
197
  # silent second run. Retried briefly, then logged with the jid so that
@@ -156,9 +206,12 @@ module Cogworker
156
206
  sleep(0.1 * attempts)
157
207
  retry
158
208
  end
209
+ # Remembered, so this process's own reconcile finishes the ack later
210
+ # instead of mistaking the entry for a stray and re-running it.
211
+ @manager.settle_later(work.raw_job, :ack)
159
212
  Cogworker.logger.error do
160
213
  "couldn't acknowledge finished job jid=#{jid_of(work)} after #{attempts} attempts " \
161
- "(it may run again on shutdown): #{e.class}: #{e.message}"
214
+ "(will retry; it may run again if this process stops first): #{e.class}: #{e.message}"
162
215
  end
163
216
  end
164
217
  end
@@ -169,17 +222,25 @@ module Cogworker
169
222
  '?'
170
223
  end
171
224
 
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).
225
+ # Returns how `settle` should dispose of the fetched job. If it raises
226
+ # instead, `process_one` falls back on `@ran` — set the moment the job
227
+ # has run — or, before that, on :give_back.
175
228
  def execute(work)
176
229
  job = parse_job(work)
177
230
  return :acknowledge unless job
178
231
 
179
- register_in_workers(work.queue, job)
232
+ # Best-effort: for the reliable fetch the in-progress list is what
233
+ # keeps the job safe; `cogworker:workers` only feeds the Workers tab
234
+ # (and BasicFetch's shutdown requeue). A failure here — even a
235
+ # deterministic one, the key holding the wrong type — used to hand
236
+ # the job back before it ever ran, over and over.
237
+ best_effort('register') { register_in_workers(work.queue, job) }
180
238
  Cogworker.logger.info { "start: #{job['class']} jid=#{job['jid']}" }
181
239
  begin
182
240
  error = run_job(work, job)
241
+ @manager.job_performed(work.raw_job)
242
+ @performed = true
243
+ @ran = error ? [:interrupt, job, error] : :acknowledge
183
244
  error ? finish_failure(job, error) : finish_success(job)
184
245
  ensure
185
246
  best_effort('deregister') { deregister_from_workers }
@@ -235,7 +296,9 @@ module Cogworker
235
296
  Cogworker.config.redis { |c| c.incr(RedisKeys::STATS_FAILED) }
236
297
  Throughput.record('failed')
237
298
  end
238
- Cogworker.logger.warn { "fail: #{job['class']} jid=#{job['jid']}: #{error.class}: #{JobUtil.error_message(error)}" }
299
+ Cogworker.logger.warn do
300
+ "fail: #{job['class']} jid=#{job['jid']}: #{error.class}: #{JobUtil.error_message(error)}"
301
+ end
239
302
  route_failure(job, error) ? :acknowledge : [:interrupt, job, error]
240
303
  end
241
304
 
@@ -299,7 +362,12 @@ module Cogworker
299
362
  begin
300
363
  payload = JSON.generate(job)
301
364
  Cogworker.config.redis do |c|
302
- retrying ? c.zadd(RedisKeys::RETRY, Time.now.to_f + delay, payload) : c.zadd(RedisKeys::DEAD, Time.now.to_f, payload)
365
+ if retrying
366
+ c.zadd(RedisKeys::RETRY, Time.now.to_f + delay,
367
+ payload)
368
+ else
369
+ c.zadd(RedisKeys::DEAD, Time.now.to_f, payload)
370
+ end
303
371
  end
304
372
  rescue StandardError => e
305
373
  Cogworker.logger.error { "couldn't record failure of jid=#{job['jid']}: #{e.class}: #{e.message}" }
@@ -329,6 +397,13 @@ module Cogworker
329
397
  end
330
398
  end
331
399
 
400
+ # Into dead by way of `interrupt` — possibly with retries still left,
401
+ # so the middleware that normally releases a terminal failure's locks
402
+ # didn't: release them here (owner-checked).
403
+ def release_locks(job)
404
+ Cogworker.config.redis { |c| JobUtil.release_terminal_locks(c, job) }
405
+ end
406
+
332
407
  # Grows with the retry count, with jitter to avoid a thundering herd of
333
408
  # retries all landing on the same second.
334
409
  def retry_delay(count)
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cogworker
4
+ # Telling "Redis is unavailable right now" from every other error, and
5
+ # keeping the log readable while it is.
6
+ #
7
+ # Unavailable means a lost connection, or one of the replies a server
8
+ # gives while it can't serve writes for a while — mid-failover (READONLY,
9
+ # MASTERDOWN), still loading its data (LOADING), or busy with a script
10
+ # (BUSY, TRYAGAIN). None of those say anything about the job, entry or key
11
+ # being worked on, so nothing should back off, count a failure, or give up
12
+ # on them; they just mean "try again shortly".
13
+ #
14
+ # `report` logs such errors once per `site` (a short name of the place
15
+ # they come from) as they start, then a one-line summary at most every
16
+ # SUMMARY_INTERVAL, and `recovered(site)` logs once that it's over. A 6
17
+ # minute outage used to produce thousands of identical lines. Any other
18
+ # error is logged in full, every time.
19
+ module RedisErrors
20
+ UNAVAILABLE_REPLIES = %w[READONLY LOADING MASTERDOWN TRYAGAIN BUSY].freeze
21
+ SUMMARY_INTERVAL = 60
22
+ SCRIPT_WRAPPED = /-(?:#{UNAVAILABLE_REPLIES.join('|')})(?=\s|\z)/
23
+
24
+ @mutex = Mutex.new
25
+ @outages = {} # site => { count:, since:, summarized_at:, error: }
26
+
27
+ class << self
28
+ def unavailable?(error)
29
+ return true if error.is_a?(Redis::BaseConnectionError)
30
+ # redis-rb 5's pub/sub path can surface redis-client's own error class.
31
+ return true if defined?(RedisClient::ConnectionError) && error.is_a?(RedisClient::ConnectionError)
32
+
33
+ return false unless error.is_a?(Redis::CommandError)
34
+
35
+ message = error.message.to_s
36
+ # The reply's code is its whole first word — BUSYKEY or BUSYGROUP
37
+ # aren't BUSY. Inside a script, Redis 6.x wraps the code instead:
38
+ # "ERR Error running script (...): ... -READONLY You can't ...".
39
+ UNAVAILABLE_REPLIES.include?(message[/\A\S+/]) ||
40
+ (message.include?('Error running script') && message.match?(SCRIPT_WRAPPED))
41
+ end
42
+
43
+ # Logs `error` at `site`; the block, if given, adds context to the
44
+ # first line (e.g. what was being done).
45
+ def report(site, error, &context)
46
+ detail = "#{error.class}: #{error.message}"
47
+ detail = "#{context.call} — #{detail}" if context
48
+ return Cogworker.logger.error { "#{site}: #{detail}" } unless unavailable?(error)
49
+
50
+ line = @mutex.synchronize { track(site, error, detail) }
51
+ Cogworker.logger.error { line } if line
52
+ end
53
+
54
+ # Call wherever `site` has just done its job: ends an outage there.
55
+ def recovered(site)
56
+ outage = @mutex.synchronize { @outages.delete(site) }
57
+ return unless outage
58
+
59
+ Cogworker.logger.warn do
60
+ "#{site}: Redis available again after #{outage[:count]} error(s) over #{(now - outage[:since]).round}s"
61
+ end
62
+ end
63
+
64
+ def reset!
65
+ @mutex.synchronize { @outages.clear }
66
+ end
67
+
68
+ private
69
+
70
+ def track(site, error, detail)
71
+ outage = @outages[site]
72
+ unless outage
73
+ @outages[site] = { count: 1, since: now, summarized_at: now, suppressed: 0 }
74
+ return "#{site}: Redis unavailable — #{detail} (repeats summarized every #{SUMMARY_INTERVAL}s)"
75
+ end
76
+
77
+ outage[:count] += 1
78
+ outage[:suppressed] += 1
79
+ return unless now - outage[:summarized_at] >= SUMMARY_INTERVAL
80
+
81
+ suppressed = outage[:suppressed]
82
+ outage[:summarized_at] = now
83
+ outage[:suppressed] = 0
84
+ "#{site}: Redis still unavailable — #{suppressed} more error(s) in the last #{SUMMARY_INTERVAL}s " \
85
+ "(latest: #{error.class}: #{error.message})"
86
+ end
87
+
88
+ def now
89
+ ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
90
+ end
91
+ end
92
+ end
93
+ end
@@ -17,6 +17,19 @@ 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'
29
+ # queue => Redis time a live process last said it reads it (every
30
+ # heartbeat). Unlike QUEUES (the Web UI's listing, never pruned), this
31
+ # tells "nobody reads this queue any more" apart from "somebody does".
32
+ LIVE_QUEUES = 'cogworker:live_queues'
20
33
  QUEUES = 'cogworker:queues'
21
34
  SCHEDULE = 'cogworker:schedule'
22
35
  RETRY = 'cogworker:retry'