cogworker 0.5.0 → 0.6.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.
@@ -29,7 +29,7 @@ module Cogworker
29
29
  # separate call made first, its failure (Redis down — the usual reason
30
30
  # the push failed) skipped the rollback altogether.
31
31
  ROLLBACK_SCRIPT = <<~LUA
32
- if redis.call("GET", KEYS[1]) == ARGV[1] then
32
+ if redis.pcall("GET", KEYS[1]) == ARGV[1] then
33
33
  if ARGV[2] == "" then
34
34
  redis.call("SET", KEYS[1], tostring(tonumber(ARGV[1]) - 1))
35
35
  else
@@ -46,14 +46,16 @@ module Cogworker
46
46
  return 1
47
47
  LUA
48
48
 
49
- def initialize(manager, entries, catch_up: true, ready: -> { true })
49
+ def initialize(manager, entries, catch_up: true, replace: false, ready: -> { true })
50
50
  @manager = manager
51
51
  @entries = entries
52
52
  @catch_up = catch_up
53
+ @replace = replace
53
54
  @ready = ready
54
55
  @last_checked_slot = {}
55
56
  @cron_cache = {}
56
57
  @pending_rollbacks = []
58
+ @blocked_since = {} # pjid => [slot, monotonic time it was first found blocked]
57
59
  @failures = Hash.new(0) # pjid => consecutive failed ticks
58
60
  @retry_at = {} # pjid => monotonic time it may be tried again
59
61
  end
@@ -83,8 +85,12 @@ module Cogworker
83
85
  # here (not at DSL-registration time in Config#periodic) so it never
84
86
  # depends on `config.redis =` having already run — every process that
85
87
  # boots re-writes the same idempotent entries regardless of ordering.
88
+ # With `replace` (Config#periodic(replace: true)) the hash is swapped
89
+ # out wholesale in one MULTI, dropping entries no longer registered
90
+ # (their `last_slot`/`disabled` state is left alone: an old process
91
+ # still ticking them mid-deploy must not re-fire an already-run slot).
86
92
  def publish_schedule!
87
- return if @entries.empty?
93
+ return if @entries.empty? && !@replace
88
94
 
89
95
  payloads = @entries.each_with_object({}) do |entry, h|
90
96
  h[entry.pjid] = JSON.generate(
@@ -92,7 +98,14 @@ module Cogworker
92
98
  'unique' => entry.unique, 'args' => entry.args
93
99
  )
94
100
  end
95
- Cogworker.config.redis { |c| c.hset(RedisKeys::PERIODIC_SCHEDULE, *payloads.to_a.flatten) }
101
+ Cogworker.config.redis do |c|
102
+ next c.hset(RedisKeys::PERIODIC_SCHEDULE, *payloads.to_a.flatten) unless @replace
103
+
104
+ c.multi do |tx|
105
+ tx.del(RedisKeys::PERIODIC_SCHEDULE)
106
+ tx.hset(RedisKeys::PERIODIC_SCHEDULE, *payloads.to_a.flatten) unless payloads.empty?
107
+ end
108
+ end
96
109
  end
97
110
 
98
111
  # A failed tick is logged and retried next interval rather than ending
@@ -113,12 +126,16 @@ module Cogworker
113
126
 
114
127
  def tick
115
128
  retry_pending_rollbacks
129
+ # This process's own delayed lock releases first (see BLOCKED_GRACE).
130
+ BestEffort.call('Deferred lock releases') { Cogworker.config.redis { |c| DeferredReleases.retry_all(c) } }
116
131
  now = Time.now
117
132
  @entries.each do |entry|
118
133
  next if backing_off?(entry.pjid)
119
134
 
120
- tick_entry(entry, now)
135
+ next if tick_entry(entry, now) == :skipped # (no Redis call made: proves nothing)
136
+
121
137
  @failures.delete(entry.pjid)
138
+ RedisErrors.recovered('Periodic tick')
122
139
  rescue StandardError => e
123
140
  # Per entry: one entry failing (its keys of the wrong type, its
124
141
  # push failing) used to skip every entry after it, on every tick.
@@ -128,8 +145,8 @@ module Cogworker
128
145
  # a lost connection, though: that's Redis being away, not the
129
146
  # entry failing — backing off every entry on it left cron idle for
130
147
  # 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}" }
148
+ delay = RedisErrors.unavailable?(e) ? TICK_INTERVAL : back_off(entry.pjid)
149
+ RedisErrors.report('Periodic tick', e) { "#{entry.pjid} failed (next try in #{delay}s)" }
133
150
  end
134
151
  end
135
152
 
@@ -149,12 +166,32 @@ module Cogworker
149
166
 
150
167
  def tick_entry(entry, now)
151
168
  slot = cron_for(entry).previous_time(now).to_i
152
- return if @last_checked_slot[entry.pjid] == slot
169
+ return :skipped if @last_checked_slot[entry.pjid] == slot
153
170
 
154
171
  jid = SecureRandom.hex(12)
155
172
  previous = claim(entry, slot, jid) unless disabled?(entry)
156
- enqueue(entry, slot, jid, previous) if previous
173
+ return if previous == :blocked && still_blocked_in_grace?(entry.pjid, slot)
174
+
175
+ enqueue(entry, slot, jid, previous) if previous && previous != :blocked
157
176
  @last_checked_slot[entry.pjid] = slot
177
+ @blocked_since.delete(entry.pjid)
178
+ end
179
+
180
+ # A slot blocked by the entry's running lock is retried for
181
+ # BLOCKED_GRACE before it counts as skipped. After an outage, the lock
182
+ # may still be held only because its finished run's release was
183
+ # deferred (DeferredReleases — retried on the next heartbeat, in
184
+ # whichever process ran it); marking the slot checked straight away
185
+ # lost it, in every process, until the entry's next slot — an hour,
186
+ # for an hourly entry. A run genuinely still going keeps the lock past
187
+ # the grace, and its slot is skipped as `until_executed` intends.
188
+ BLOCKED_GRACE = 3 * Heartbeat::INTERVAL
189
+
190
+ def still_blocked_in_grace?(pjid, slot)
191
+ now = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
192
+ since_slot, since = @blocked_since[pjid]
193
+ @blocked_since[pjid] = [slot, since = now] unless since_slot == slot
194
+ now - since < BLOCKED_GRACE
158
195
  end
159
196
 
160
197
  # The usual reason a push fails — Redis unreachable — makes this fail
@@ -175,7 +212,7 @@ module Cogworker
175
212
  end
176
213
  true
177
214
  rescue StandardError => e
178
- Cogworker.logger.error { "periodic #{entry.pjid}: couldn't roll back slot #{slot} (will retry): #{e.class}: #{e.message}" }
215
+ RedisErrors.report('Periodic rollback', e) { "#{entry.pjid}: couldn't roll back slot #{slot} (will retry)" }
179
216
  @pending_rollbacks << [entry, slot, previous_slot, jid]
180
217
  e # (truthy, but not `true`: the caller tells a lost connection from a failing rollback)
181
218
  end
@@ -194,7 +231,8 @@ module Cogworker
194
231
  if result == true
195
232
  @failures.delete(key)
196
233
  @retry_at.delete(key)
197
- elsif !result.is_a?(Redis::BaseConnectionError)
234
+ RedisErrors.recovered('Periodic rollback')
235
+ elsif !RedisErrors.unavailable?(result)
198
236
  back_off(key) # only a rollback failing on its own backs off; see #tick
199
237
  end
200
238
  end
@@ -218,9 +256,9 @@ module Cogworker
218
256
  # connection still raises (the tick is then retried as a whole).
219
257
  def disabled?(entry)
220
258
  Cogworker.config.redis { |c| c.sismember(RedisKeys::PERIODIC_DISABLED, entry.pjid) }
221
- rescue Redis::BaseConnectionError
222
- raise
223
259
  rescue StandardError => e
260
+ raise if RedisErrors.unavailable?(e)
261
+
224
262
  Cogworker.logger.error { "Periodic: can't read #{RedisKeys::PERIODIC_DISABLED} (#{e.class}: #{e.message}); treating #{entry.pjid} as enabled" }
225
263
  false
226
264
  end
@@ -229,17 +267,20 @@ module Cogworker
229
267
  @cron_cache[entry.pjid] ||= Fugit::Cron.parse(entry.cron)
230
268
  end
231
269
 
232
- # nil if this process didn't win the slot; otherwise the previous
233
- # `last_slot` value ("" if none), for #rollback_claim.
270
+ # nil if this process didn't win the slot, :blocked if the entry's
271
+ # running lock stopped it; otherwise the previous `last_slot` value
272
+ # ("" if none), for #rollback_claim.
234
273
  def claim(entry, slot, jid)
235
274
  return nil if !@catch_up && priming_first_slot?(entry, slot)
236
275
 
237
276
  result = Cogworker.config.redis do |c|
238
277
  LuaScript.run(c, CLAIM_SCRIPT,
239
- keys: [RedisKeys.periodic_running(entry.pjid), RedisKeys.periodic_last_slot(entry.pjid),
240
- RedisKeys.periodic_lock(entry.pjid, slot)],
241
- argv: [slot, entry.unique.to_s, LOCK_TTL, jid, RunningLock.queued_ttl])
278
+ keys: [RedisKeys.periodic_running(entry.pjid), RedisKeys.periodic_last_slot(entry.pjid),
279
+ RedisKeys.periodic_lock(entry.pjid, slot)],
280
+ argv: [slot, entry.unique.to_s, LOCK_TTL, jid, RunningLock.queued_ttl])
242
281
  end
282
+ return :blocked if result == 2
283
+
243
284
  result.is_a?(Array) ? result[1].to_s : nil
244
285
  end
245
286
 
@@ -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
@@ -89,11 +89,13 @@ module Cogworker
89
89
  @manager.job_started(work.raw_job)
90
90
  @busy = true
91
91
  @ran = nil
92
+ @performed = false
92
93
  disposition = nil
93
94
  begin
94
95
  disposition = execute(work)
95
96
  ensure
96
97
  @busy = false
98
+ @manager.job_performed(work.raw_job) unless @performed
97
99
  @manager.processor_idle!
98
100
  # `execute` raising leaves `disposition` unset: before the job ran
99
101
  # that's a hand-back; once it ran, whatever its outcome dictates
@@ -155,7 +157,11 @@ module Cogworker
155
157
  @manager.settle_later(work.raw_job, [set, score, encoded]) if fetcher.is_a?(ReliableFetch)
156
158
  raise
157
159
  end
158
- best_effort('lock extension') { extend_locks_for_retry(job, delay) } if set == RedisKeys::RETRY
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
159
165
  reason = if set == RedisKeys::RETRY
160
166
  "retrying in #{delay}s (interruption #{count} of at most #{MAX_INTERRUPTS})"
161
167
  elsif out_of_retries
@@ -176,9 +182,11 @@ module Cogworker
176
182
  end
177
183
 
178
184
  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))
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))
182
190
  @manager.settle_later(work.raw_job, [RedisKeys::DEAD, Time.now.to_f, entry])
183
191
  rescue StandardError
184
192
  nil
@@ -230,6 +238,8 @@ module Cogworker
230
238
  Cogworker.logger.info { "start: #{job['class']} jid=#{job['jid']}" }
231
239
  begin
232
240
  error = run_job(work, job)
241
+ @manager.job_performed(work.raw_job)
242
+ @performed = true
233
243
  @ran = error ? [:interrupt, job, error] : :acknowledge
234
244
  error ? finish_failure(job, error) : finish_success(job)
235
245
  ensure
@@ -286,7 +296,9 @@ module Cogworker
286
296
  Cogworker.config.redis { |c| c.incr(RedisKeys::STATS_FAILED) }
287
297
  Throughput.record('failed')
288
298
  end
289
- 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
290
302
  route_failure(job, error) ? :acknowledge : [:interrupt, job, error]
291
303
  end
292
304
 
@@ -350,7 +362,12 @@ module Cogworker
350
362
  begin
351
363
  payload = JSON.generate(job)
352
364
  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)
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
354
371
  end
355
372
  rescue StandardError => e
356
373
  Cogworker.logger.error { "couldn't record failure of jid=#{job['jid']}: #{e.class}: #{e.message}" }
@@ -380,6 +397,13 @@ module Cogworker
380
397
  end
381
398
  end
382
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
+
383
407
  # Grows with the retry count, with jitter to avoid a thundering herd of
384
408
  # retries all landing on the same second.
385
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
@@ -26,6 +26,10 @@ module Cogworker
26
26
  # over by a process shutting down (JSON {set, score, payload} each) —
27
27
  # see ReliableFetch.park_unsettled / file_unsettled.
28
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'
29
33
  QUEUES = 'cogworker:queues'
30
34
  SCHEDULE = 'cogworker:schedule'
31
35
  RETRY = 'cogworker:retry'