cogworker 0.5.0 → 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.
@@ -43,16 +43,29 @@ module Cogworker
43
43
  end
44
44
  end
45
45
 
46
+ # The queue listing only feeds the Web UI/stats; failing to update it
47
+ # is logged, never raised — whatever the error, Redis being away
48
+ # included: the job is already queued by then, and raising would make
49
+ # the caller push it a second time (see raw_push).
50
+ def register_queue(conn, queue)
51
+ conn.sadd?(RedisKeys::QUEUES, queue)
52
+ rescue StandardError => e
53
+ RedisErrors.report("#{RedisKeys::QUEUES} not updated", e) { queue }
54
+ end
55
+
46
56
  def raw_push(job)
47
57
  Cogworker.config.redis do |conn|
48
58
  if job['at']
49
59
  conn.zadd(RedisKeys::SCHEDULE, job['at'].to_f, JSON.generate(job))
50
60
  else
51
61
  job['enqueued_at'] = Time.now.to_f
52
- conn.multi do |pipeline|
53
- pipeline.sadd?(RedisKeys::QUEUES, job['queue'])
54
- pipeline.lpush(RedisKeys.queue(job['queue']), JSON.generate(job))
55
- end
62
+ # The push is the one write that matters, and goes first, on its
63
+ # own. They used to share a MULTI — which doesn't roll back: with
64
+ # `cogworker:queues` unwritable the job was queued anyway but the
65
+ # whole push raised, so callers retried it (the cron ticker did,
66
+ # every tick) and queued it again and again.
67
+ conn.lpush(RedisKeys.queue(job['queue']), JSON.generate(job))
68
+ register_queue(conn, job['queue'])
56
69
  end
57
70
  end
58
71
  end
@@ -61,7 +61,10 @@ module Cogworker
61
61
  end
62
62
 
63
63
  def max_orphanings=(count)
64
- raise ArgumentError, "max_orphanings must be a positive Integer, got #{count.inspect}" unless count.is_a?(Integer) && count.positive?
64
+ unless count.is_a?(Integer) && count.positive?
65
+ raise ArgumentError,
66
+ "max_orphanings must be a positive Integer, got #{count.inspect}"
67
+ end
65
68
 
66
69
  @max_orphanings = count
67
70
  end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cogworker
4
+ # Lock releases that failed (Redis away when a job finished) — kept here
5
+ # and retried on every heartbeat beat (Heartbeat#beat) until they go
6
+ # through, owner-checked like the original release. A failed release is
7
+ # best-effort so it can't fail the job, but left alone it held the lock
8
+ # until its TTL: an `until_executed` cron entry skipped slots for up to
9
+ # `RunningLock.active_ttl`, a unique job blocked its duplicates for up to
10
+ # `unique_lock_ttl`. In this process's memory only: one still pending when
11
+ # the process exits falls back to the lock's TTL.
12
+ module DeferredReleases
13
+ @mutex = Mutex.new
14
+ @pending = {} # lock key => owner jid
15
+
16
+ class << self
17
+ def add(key, owner)
18
+ @mutex.synchronize { @pending[key] = owner }
19
+ end
20
+
21
+ # Retries every pending release; stops at the first one Redis can't
22
+ # serve (no point hammering it), keeps whatever's left.
23
+ def retry_all(conn)
24
+ pending.each do |key, owner|
25
+ OwnedKey.delete(key, owner, conn) # (a no-op if it lapsed or changed hands meanwhile)
26
+ @mutex.synchronize { @pending.delete(key) if @pending[key] == owner }
27
+ rescue StandardError => e
28
+ raise if RedisErrors.unavailable?(e)
29
+
30
+ # Anything else (the key holding the wrong type, say) won't get
31
+ # better by retrying every 5s: dropped, logged once.
32
+ @mutex.synchronize { @pending.delete(key) if @pending[key] == owner }
33
+ Cogworker.logger.error { "deferred release of #{key} dropped: #{e.class}: #{e.message}" }
34
+ end
35
+ end
36
+
37
+ def pending
38
+ @mutex.synchronize { @pending.dup }
39
+ end
40
+
41
+ def reset!
42
+ @mutex.synchronize { @pending.clear }
43
+ end
44
+ end
45
+ end
46
+ end
@@ -99,9 +99,12 @@ module Cogworker
99
99
 
100
100
  def beat_safely
101
101
  beat
102
+ @beat_failed = false
103
+ RedisErrors.recovered('Heartbeat')
102
104
  true
103
105
  rescue StandardError => e
104
- Cogworker.logger.error { "Heartbeat failed: #{e.class}: #{e.message}" }
106
+ @beat_failed = true
107
+ RedisErrors.report('Heartbeat', e)
105
108
  false
106
109
  end
107
110
 
@@ -128,21 +131,43 @@ module Cogworker
128
131
  'busy', @manager.busy_count.to_s,
129
132
  'quiet', @manager.quiet?.to_s)
130
133
  c.expire(RedisKeys.process(identity), TTL)
131
- secondary('process registry') { c.sadd?(RedisKeys::PROCESSES, identity) }
132
- secondary('workers expiry') { c.expire(RedisKeys.workers(identity), TTL) }
133
- if reliable
134
- secondary('in-progress registry') { c.sadd?(RedisKeys::IN_PROGRESS_IDENTITIES, identity) }
135
- secondary('last-beat stamp') { c.hset(RedisKeys::LAST_BEAT, identity, redis_now(c)) }
136
- end
134
+ write_registries(c, identity, reliable)
137
135
  touch_running_periodic_locks(c, identity)
136
+ # Not via #secondary: Redis failing here, after the presence key went
137
+ # through, doesn't make this beat a failed one.
138
+ BestEffort.call('Deferred lock releases') { DeferredReleases.retry_all(c) }
138
139
  end
139
140
  end
140
141
 
142
+ # Everything a beat writes besides the presence key — each on its own,
143
+ # best-effort (see #secondary).
144
+ def write_registries(conn, identity, reliable)
145
+ secondary('process registry') { conn.sadd?(RedisKeys::PROCESSES, identity) }
146
+ # The queues this process reads are listed even while empty: the Web
147
+ # UI shows them, and recovery can tell a queue nobody reads from one
148
+ # that's merely had nothing pushed yet.
149
+ secondary('queue registry') { conn.sadd(RedisKeys::QUEUES, @manager.queues.uniq) }
150
+ secondary('live queue registry') { mark_queues_live(conn) }
151
+ secondary('workers expiry') { conn.expire(RedisKeys.workers(identity), TTL) }
152
+ return unless reliable
153
+
154
+ secondary('in-progress registry') { conn.sadd?(RedisKeys::IN_PROGRESS_IDENTITIES, identity) }
155
+ secondary('last-beat stamp') { conn.hset(RedisKeys::LAST_BEAT, identity, redis_now(conn)) }
156
+ end
157
+
158
+ # Stamps this process's queues as read right now, and drops ones no
159
+ # process has stamped for a day (only ever a listing, never data).
160
+ def mark_queues_live(conn)
161
+ now = redis_now(conn)
162
+ conn.zadd(RedisKeys::LIVE_QUEUES, @manager.queues.uniq.map { |q| [now, q] })
163
+ conn.zremrangebyscore(RedisKeys::LIVE_QUEUES, '-inf', now - 86_400)
164
+ end
165
+
141
166
  def secondary(what)
142
167
  yield
143
- rescue Redis::BaseConnectionError
144
- raise # Redis itself is gone: the beat as a whole failed
145
168
  rescue StandardError => e
169
+ raise if RedisErrors.unavailable?(e) # Redis itself is away: the beat as a whole failed
170
+
146
171
  Cogworker.logger.error { "Heartbeat: #{what} not written: #{e.class}: #{e.message}" }
147
172
  end
148
173
 
@@ -161,12 +186,30 @@ module Cogworker
161
186
  # see Periodic::RunningLock. From the Manager's own in-memory record of
162
187
  # what's running here, not `cogworker:workers` in Redis: a failover that
163
188
  # lost that Hash would otherwise let a long run's lock lapse mid-run.
189
+ # (One narrow race remains: a release landing between this beat's
190
+ # snapshot of `performing_jobs` and its Lua call, on the first beat
191
+ # after a failed one, can be undone — the lock then lapses on
192
+ # `active_ttl`. Not worth a cross-thread lock on every beat.)
193
+ # Only jobs still inside `perform` (`performing_jobs`): once it returns,
194
+ # Periodic::ReleaseMiddleware has released the lock, and refreshing it
195
+ # again would bring it back. A missing lock is only *re-taken* on the
196
+ # first beat after a failed one — i.e. after Redis was away, possibly
197
+ # long enough for the lock to lapse; otherwise a missing lock means it
198
+ # was released, and stays released.
164
199
  def touch_running_periodic_locks(conn, _identity)
165
- @manager.running_jobs.each do |raw|
200
+ recovering = @beat_failed
201
+ @manager.performing_jobs.each do |raw|
166
202
  job = JSON.parse(raw)
167
203
  next unless job.is_a?(Hash) && job['periodic_pjid']
168
204
 
169
- Periodic::RunningLock.touch(job['periodic_pjid'], job['jid'], Periodic::RunningLock.active_ttl, conn)
205
+ if recovering && job['periodic_until_executed']
206
+ # Re-taken, not just extended: after Redis was away longer than
207
+ # `active_ttl`, the lock is gone while the run is still going, and
208
+ # the ticker could start the next slot alongside it.
209
+ Periodic::RunningLock.retake(job['periodic_pjid'], job['jid'], Periodic::RunningLock.active_ttl, conn)
210
+ else
211
+ Periodic::RunningLock.touch(job['periodic_pjid'], job['jid'], Periodic::RunningLock.active_ttl, conn)
212
+ end
170
213
  rescue StandardError => e
171
214
  # Per entry: one bad entry mustn't leave the others' locks to expire.
172
215
  Cogworker.logger.error { "couldn't refresh periodic lock for #{raw.to_s[0, 200]}: #{e.class}: #{e.message}" }
@@ -199,6 +242,7 @@ module Cogworker
199
242
  def subscribe_loop
200
243
  @subscribe_client = ::Redis.new(RedisConnection.client_options(Cogworker.config.redis_options))
201
244
  @subscribe_client.subscribe(RedisKeys.signal(Cogworker.identity)) do |on|
245
+ on.subscribe { |*| RedisErrors.recovered('Signal subscriber') }
202
246
  on.message do |_channel, message|
203
247
  dispatch(message)
204
248
  end
@@ -206,7 +250,7 @@ module Cogworker
206
250
  rescue StandardError => e
207
251
  return if @stopping
208
252
 
209
- Cogworker.logger.error { "Signal subscriber died: #{e.class}: #{e.message}" }
253
+ RedisErrors.report('Signal subscriber', e)
210
254
  sleep(1)
211
255
  retry
212
256
  end
@@ -5,13 +5,17 @@ module Cogworker
5
5
  # Server middleware: times every run and records it via Storage,
6
6
  # success or failure, regardless of what other middleware/worker code does.
7
7
  class Middleware
8
+ # Recording is best-effort (BestEffort): only the job's own exception
9
+ # is re-raised, never one from writing its history.
8
10
  def call(_worker, job, queue)
9
11
  started_at = Time.now.to_f
10
- yield
11
- Storage.record(job, queue, started_at, Time.now.to_f, 'success')
12
- rescue Exception => e # rubocop:disable Lint/RescueException
13
- Storage.record(job, queue, started_at, Time.now.to_f, 'failed', error: e)
14
- raise e
12
+ begin
13
+ yield
14
+ rescue Exception => e # rubocop:disable Lint/RescueException
15
+ BestEffort.call('History') { Storage.record(job, queue, started_at, Time.now.to_f, 'failed', error: e) }
16
+ raise e
17
+ end
18
+ BestEffort.call('History') { Storage.record(job, queue, started_at, Time.now.to_f, 'success') }
15
19
  end
16
20
  end
17
21
  end
@@ -142,6 +142,23 @@ module Cogworker
142
142
  "#<#{error.class}>"
143
143
  end
144
144
 
145
+ # A job on its way to dead outside the usual path (no server middleware
146
+ # around it to do this): releases its `until_executed` locks, owner-
147
+ # checked, each on its own — so the entry / the unique key isn't blocked
148
+ # until the locks' TTL. Best-effort.
149
+ def release_terminal_locks(conn, job)
150
+ keys = []
151
+ keys << RedisKeys.unique_lock(UniqueJobs.digest(job)) if UniqueJobs.until_executed?(job)
152
+ keys << RedisKeys.periodic_running(job['periodic_pjid']) if job['periodic_pjid']
153
+ keys.each do |key|
154
+ released = BestEffort.call('Lock release') do
155
+ OwnedKey.delete(key, job['jid'], conn)
156
+ true
157
+ end
158
+ DeferredReleases.add(key, job['jid']) unless released
159
+ end
160
+ end
161
+
145
162
  UNPARSEABLE_CLASS = '(unparseable)'
146
163
 
147
164
  # A payload that isn't a JSON object can't be run, retried or even
@@ -22,6 +22,7 @@ module Cogworker
22
22
  RedisKeys::QUEUES => 'set', RedisKeys::PROCESSES => 'set', RedisKeys::PAUSED_QUEUES => 'set',
23
23
  RedisKeys::IN_PROGRESS_IDENTITIES => 'set', RedisKeys::LAST_BEAT => 'hash',
24
24
  RedisKeys::REPEAT_ORPHANS => 'list', RedisKeys::UNSETTLED => 'list', RedisKeys::PERIODIC_SCHEDULE => 'hash',
25
+ RedisKeys::LIVE_QUEUES => 'zset',
25
26
  RedisKeys::PERIODIC_DISABLED => 'set', RedisKeys::STATS_PROCESSED => 'string',
26
27
  RedisKeys::STATS_FAILED => 'string'
27
28
  }.freeze
@@ -65,9 +66,41 @@ module Cogworker
65
66
  types[RedisKeys.process(id)] = 'hash'
66
67
  types[RedisKeys.workers(id)] = 'hash'
67
68
  end
69
+ readable_fields(RedisKeys::PERIODIC_SCHEDULE).each do |pjid|
70
+ types[RedisKeys.periodic_running(pjid)] = 'string'
71
+ types[RedisKeys.periodic_last_slot(pjid)] = 'string'
72
+ end
68
73
  types
69
74
  end
70
75
 
76
+ def readable_fields(hash)
77
+ Cogworker.config.redis { |c| c.hkeys(hash) }
78
+ rescue Redis::CommandError
79
+ []
80
+ end
81
+
82
+ MAX_COPIES = 5
83
+
84
+ # Keeps the newest MAX_COPIES quarantined copies of one key: something
85
+ # that keeps writing the wrong type would otherwise leave one more every
86
+ # CHECK_INTERVAL, for QUARANTINE_TTL. (A SCAN, but only on the rare
87
+ # occasion a key actually gets quarantined.)
88
+ def prune_copies(key)
89
+ prefix = "#{QUARANTINE_PREFIX}#{key}:"
90
+ exact = /\A#{Regexp.escape(prefix)}(\d+):\h{8}\z/ # this key's copies only — not `<key>:<more>:...`'s
91
+ Cogworker.config.redis do |c|
92
+ copies = c.scan_each(match: "#{glob_escape(prefix)}*").select { |name| name.match?(exact) }
93
+ stale = copies.sort_by { |name| name[exact, 1].to_i }.reverse.drop(MAX_COPIES)
94
+ c.del(*stale) unless stale.empty?
95
+ end
96
+ rescue StandardError => e
97
+ Cogworker.logger.error { "pruning quarantined copies of #{key} failed: #{e.class}: #{e.message}" }
98
+ end
99
+
100
+ def glob_escape(text)
101
+ text.gsub(/[*?\[\]\\]/) { |ch| "\\#{ch}" }
102
+ end
103
+
71
104
  def readable_members(set)
72
105
  Cogworker.config.redis { |c| c.smembers(set) }
73
106
  rescue Redis::CommandError
@@ -77,12 +110,14 @@ module Cogworker
77
110
  def quarantine(key, expected, actual)
78
111
  # Unique per move: a second quarantine of the same key within the same
79
112
  # second must not overwrite (RENAME) the first one's copy.
80
- target = "#{QUARANTINE_PREFIX}#{key}:#{Time.now.to_i}:#{SecureRandom.hex(4)}"
113
+ # Milliseconds, so copies made within one second still sort by age.
114
+ target = "#{QUARANTINE_PREFIX}#{key}:#{(Time.now.to_f * 1000).to_i}:#{SecureRandom.hex(4)}"
81
115
  moved = Cogworker.config.redis do |c|
82
116
  LuaScript.run(c, QUARANTINE_SCRIPT, keys: [key, target], argv: [expected, QUARANTINE_TTL])
83
117
  end
84
118
  return false unless moved == 1
85
119
 
120
+ prune_copies(key)
86
121
  Cogworker.logger.error do
87
122
  "#{key} held a #{actual}, not a #{expected}: moved to #{target} (kept #{QUARANTINE_TTL / 86_400} days)"
88
123
  end
@@ -45,6 +45,9 @@ module Cogworker
45
45
 
46
46
  def stop!
47
47
  @manager.stop!
48
+ # Last chance for releases deferred by a recent outage — they live in
49
+ # this process's memory and would otherwise wait out their locks' TTL.
50
+ BestEffort.call('Deferred lock releases') { @config.redis { |c| DeferredReleases.retry_all(c) } }
48
51
  @scheduled.stop!
49
52
  @ticker.stop!
50
53
  @heartbeat.stop!
@@ -9,8 +9,8 @@ module Cogworker
9
9
  # restarted or failed-over server, SCRIPT FLUSH). Every script in this
10
10
  # gem goes through here.
11
11
  module LuaScript
12
- SHAS = {} # source => sha1; memoized, the source strings are frozen constants
13
- SHAS_MUTEX = Mutex.new
12
+ @shas = {} # source => sha1; memoized, the sources are frozen constants
13
+ @shas_mutex = Mutex.new
14
14
 
15
15
  module_function
16
16
 
@@ -24,7 +24,7 @@ module Cogworker
24
24
  end
25
25
 
26
26
  def sha(source)
27
- SHAS_MUTEX.synchronize { SHAS[source] ||= Digest::SHA1.hexdigest(source) }
27
+ @shas_mutex.synchronize { @shas[source] ||= Digest::SHA1.hexdigest(source) }
28
28
  end
29
29
  end
30
30
  end
@@ -16,6 +16,7 @@ module Cogworker
16
16
  # be what's failing (or what lost its last writes in a failover).
17
17
  @jobs_mutex = Mutex.new
18
18
  @running_jobs = Hash.new(0)
19
+ @performing_jobs = Hash.new(0) # the subset still inside `perform`
19
20
  @pending_settlements = {}
20
21
  @processors = Array.new(config.concurrency) { Processor.new(self) }
21
22
  @quiet = false
@@ -74,7 +75,24 @@ module Cogworker
74
75
  end
75
76
 
76
77
  def job_started(raw)
77
- @jobs_mutex.synchronize { @running_jobs[raw] += 1 }
78
+ @jobs_mutex.synchronize do
79
+ @running_jobs[raw] += 1
80
+ @performing_jobs[raw] += 1
81
+ end
82
+ end
83
+
84
+ # `perform` (with its middleware) has returned: the job is still this
85
+ # process's until settled, but its locks are no longer its to keep
86
+ # alive — Heartbeat stops refreshing them (see `performing_jobs`).
87
+ def job_performed(raw)
88
+ @jobs_mutex.synchronize do
89
+ @performing_jobs[raw] -= 1
90
+ @performing_jobs.delete(raw) unless @performing_jobs[raw].positive?
91
+ end
92
+ end
93
+
94
+ def performing_jobs
95
+ @jobs_mutex.synchronize { @performing_jobs.keys }
78
96
  end
79
97
 
80
98
  def job_finished(raw)
@@ -10,7 +10,7 @@
10
10
  -- Returns {1, <previous last_slot, or "" if none>} if this call won the
11
11
  -- claim for this slot (the caller should enqueue the job — and, if that
12
12
  -- fails, roll the claim back with ROLLBACK_SCRIPT using that previous
13
- -- value), 0 otherwise. Composes two guards: `last_slot` stops a
13
+ -- value), 2 if blocked by the entry's running lock, 0 otherwise. Composes two guards: `last_slot` stops a
14
14
  -- process re-firing the same (or an earlier) slot on a later tick, and the
15
15
  -- NX lock breaks the narrow race where two processes both pass the
16
16
  -- `last_slot` check before either has written it back.
@@ -18,8 +18,11 @@
18
18
  -- For `until_executed`, the running lock is written here, in the same
19
19
  -- atomic step as the claim and before the job exists anywhere — see
20
20
  -- Periodic::RunningLock for why it must never be written after the push.
21
+ -- Blocked by a run still holding the entry's lock: a distinct 2, so the
22
+ -- caller can retry the slot briefly — the lock may only be waiting for a
23
+ -- release delayed by an outage (see Ticker::BLOCKED_GRACE).
21
24
  if ARGV[2] == "until_executed" and redis.call("GET", KEYS[1]) then
22
- return 0
25
+ return 2
23
26
  end
24
27
 
25
28
  local previous = redis.call("GET", KEYS[2])
@@ -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
@@ -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
@@ -54,6 +54,7 @@ module Cogworker
54
54
  @last_checked_slot = {}
55
55
  @cron_cache = {}
56
56
  @pending_rollbacks = []
57
+ @blocked_since = {} # pjid => [slot, monotonic time it was first found blocked]
57
58
  @failures = Hash.new(0) # pjid => consecutive failed ticks
58
59
  @retry_at = {} # pjid => monotonic time it may be tried again
59
60
  end
@@ -113,12 +114,16 @@ module Cogworker
113
114
 
114
115
  def tick
115
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) } }
116
119
  now = Time.now
117
120
  @entries.each do |entry|
118
121
  next if backing_off?(entry.pjid)
119
122
 
120
- tick_entry(entry, now)
123
+ next if tick_entry(entry, now) == :skipped # (no Redis call made: proves nothing)
124
+
121
125
  @failures.delete(entry.pjid)
126
+ RedisErrors.recovered('Periodic tick')
122
127
  rescue StandardError => e
123
128
  # Per entry: one entry failing (its keys of the wrong type, its
124
129
  # push failing) used to skip every entry after it, on every tick.
@@ -128,8 +133,8 @@ module Cogworker
128
133
  # a lost connection, though: that's Redis being away, not the
129
134
  # entry failing — backing off every entry on it left cron idle for
130
135
  # 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}" }
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)" }
133
138
  end
134
139
  end
135
140
 
@@ -149,12 +154,32 @@ module Cogworker
149
154
 
150
155
  def tick_entry(entry, now)
151
156
  slot = cron_for(entry).previous_time(now).to_i
152
- return if @last_checked_slot[entry.pjid] == slot
157
+ return :skipped if @last_checked_slot[entry.pjid] == slot
153
158
 
154
159
  jid = SecureRandom.hex(12)
155
160
  previous = claim(entry, slot, jid) unless disabled?(entry)
156
- enqueue(entry, slot, jid, previous) if previous
161
+ return if previous == :blocked && still_blocked_in_grace?(entry.pjid, slot)
162
+
163
+ enqueue(entry, slot, jid, previous) if previous && previous != :blocked
157
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
158
183
  end
159
184
 
160
185
  # The usual reason a push fails — Redis unreachable — makes this fail
@@ -175,7 +200,7 @@ module Cogworker
175
200
  end
176
201
  true
177
202
  rescue StandardError => e
178
- Cogworker.logger.error { "periodic #{entry.pjid}: couldn't roll back slot #{slot} (will retry): #{e.class}: #{e.message}" }
203
+ RedisErrors.report('Periodic rollback', e) { "#{entry.pjid}: couldn't roll back slot #{slot} (will retry)" }
179
204
  @pending_rollbacks << [entry, slot, previous_slot, jid]
180
205
  e # (truthy, but not `true`: the caller tells a lost connection from a failing rollback)
181
206
  end
@@ -194,7 +219,8 @@ module Cogworker
194
219
  if result == true
195
220
  @failures.delete(key)
196
221
  @retry_at.delete(key)
197
- elsif !result.is_a?(Redis::BaseConnectionError)
222
+ RedisErrors.recovered('Periodic rollback')
223
+ elsif !RedisErrors.unavailable?(result)
198
224
  back_off(key) # only a rollback failing on its own backs off; see #tick
199
225
  end
200
226
  end
@@ -218,9 +244,9 @@ module Cogworker
218
244
  # connection still raises (the tick is then retried as a whole).
219
245
  def disabled?(entry)
220
246
  Cogworker.config.redis { |c| c.sismember(RedisKeys::PERIODIC_DISABLED, entry.pjid) }
221
- rescue Redis::BaseConnectionError
222
- raise
223
247
  rescue StandardError => e
248
+ raise if RedisErrors.unavailable?(e)
249
+
224
250
  Cogworker.logger.error { "Periodic: can't read #{RedisKeys::PERIODIC_DISABLED} (#{e.class}: #{e.message}); treating #{entry.pjid} as enabled" }
225
251
  false
226
252
  end
@@ -229,17 +255,20 @@ module Cogworker
229
255
  @cron_cache[entry.pjid] ||= Fugit::Cron.parse(entry.cron)
230
256
  end
231
257
 
232
- # nil if this process didn't win the slot; otherwise the previous
233
- # `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.
234
261
  def claim(entry, slot, jid)
235
262
  return nil if !@catch_up && priming_first_slot?(entry, slot)
236
263
 
237
264
  result = Cogworker.config.redis do |c|
238
265
  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])
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])
242
269
  end
270
+ return :blocked if result == 2
271
+
243
272
  result.is_a?(Array) ? result[1].to_s : nil
244
273
  end
245
274
 
@@ -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