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.
@@ -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
@@ -9,7 +9,7 @@ module Cogworker
9
9
  FETCH_MODES = %i[reliable basic].freeze
10
10
 
11
11
  attr_reader :server_chain, :client_chain, :periodic_manager, :redis_options, :fetch, :fetch_idle_max_interval,
12
- :orphan_threshold
12
+ :orphan_threshold, :max_orphanings
13
13
  attr_accessor :concurrency, :queues, :periodic_catch_up, :unique_lock_ttl
14
14
 
15
15
  def initialize
@@ -51,9 +51,24 @@ module Cogworker
51
51
  # failover, a long GVL-holding call) — each of which runs its jobs
52
52
  # twice; lower: a really crashed process's jobs come back sooner.
53
53
  @orphan_threshold = 300
54
+ # How many times a job may be recovered from a process that died
55
+ # while running it before it goes to dead instead (ReliableFetch). The
56
+ # count can't single out the culprit: every job running in a process
57
+ # that crashed is counted, so keep this above the number of crashes an
58
+ # innocent job might plausibly sit through.
59
+ @max_orphanings = 3
54
60
  register_default_middleware
55
61
  end
56
62
 
63
+ def max_orphanings=(count)
64
+ unless count.is_a?(Integer) && count.positive?
65
+ raise ArgumentError,
66
+ "max_orphanings must be a positive Integer, got #{count.inspect}"
67
+ end
68
+
69
+ @max_orphanings = count
70
+ end
71
+
57
72
  def orphan_threshold=(seconds)
58
73
  unless seconds.is_a?(Numeric) && seconds.positive?
59
74
  raise ArgumentError, "orphan_threshold must be a positive number of seconds, got #{seconds.inspect}"
@@ -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
 
@@ -117,22 +120,57 @@ module Cogworker
117
120
  'rss_kb' => current_rss_kb
118
121
  }
119
122
 
123
+ # The presence key is what makes this process alive to everyone else
124
+ # (and what Heartbeat#start! waits for) — the one write a beat can't
125
+ # do without. Everything else is written on its own, best-effort: one
126
+ # shared key of the wrong type used to fail every beat of every
127
+ # process, so no new process could start anywhere in the fleet.
120
128
  Cogworker.config.redis do |c|
121
- c.sadd?(RedisKeys::PROCESSES, identity)
122
129
  c.hset(RedisKeys.process(identity),
123
130
  'info', JSON.generate(info),
124
131
  'busy', @manager.busy_count.to_s,
125
132
  'quiet', @manager.quiet?.to_s)
126
133
  c.expire(RedisKeys.process(identity), TTL)
127
- c.expire(RedisKeys.workers(identity), TTL)
128
- if reliable
129
- c.sadd?(RedisKeys::IN_PROGRESS_IDENTITIES, identity)
130
- c.hset(RedisKeys::LAST_BEAT, identity, redis_now(c))
131
- end
134
+ write_registries(c, identity, reliable)
132
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) }
133
139
  end
134
140
  end
135
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
+
166
+ def secondary(what)
167
+ yield
168
+ rescue StandardError => e
169
+ raise if RedisErrors.unavailable?(e) # Redis itself is away: the beat as a whole failed
170
+
171
+ Cogworker.logger.error { "Heartbeat: #{what} not written: #{e.class}: #{e.message}" }
172
+ end
173
+
136
174
  # Redis's clock, not this host's (so host clock skew doesn't matter to
137
175
  # orphan detection) — falling back to this host's where `TIME` isn't
138
176
  # allowed (some proxies/managed services): better a skewed stamp than a
@@ -145,15 +183,33 @@ module Cogworker
145
183
 
146
184
  # Keeps every in-flight `until_executed` periodic run's
147
185
  # `periodic:running:<pjid>` lock alive for as long as this process is —
148
- # see Periodic::RunningLock. Reads the same `cogworker:workers:<identity>`
149
- # Hash the Workers tab does, so it covers exactly the jobs actually
150
- # executing here right now.
151
- def touch_running_periodic_locks(conn, identity)
152
- conn.hvals(RedisKeys.workers(identity)).each do |raw|
153
- job = JSON.parse(raw)['payload']
186
+ # see Periodic::RunningLock. From the Manager's own in-memory record of
187
+ # what's running here, not `cogworker:workers` in Redis: a failover that
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.
199
+ def touch_running_periodic_locks(conn, _identity)
200
+ recovering = @beat_failed
201
+ @manager.performing_jobs.each do |raw|
202
+ job = JSON.parse(raw)
154
203
  next unless job.is_a?(Hash) && job['periodic_pjid']
155
204
 
156
- 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
157
213
  rescue StandardError => e
158
214
  # Per entry: one bad entry mustn't leave the others' locks to expire.
159
215
  Cogworker.logger.error { "couldn't refresh periodic lock for #{raw.to_s[0, 200]}: #{e.class}: #{e.message}" }
@@ -186,6 +242,7 @@ module Cogworker
186
242
  def subscribe_loop
187
243
  @subscribe_client = ::Redis.new(RedisConnection.client_options(Cogworker.config.redis_options))
188
244
  @subscribe_client.subscribe(RedisKeys.signal(Cogworker.identity)) do |on|
245
+ on.subscribe { |*| RedisErrors.recovered('Signal subscriber') }
189
246
  on.message do |_channel, message|
190
247
  dispatch(message)
191
248
  end
@@ -193,7 +250,7 @@ module Cogworker
193
250
  rescue StandardError => e
194
251
  return if @stopping
195
252
 
196
- Cogworker.logger.error { "Signal subscriber died: #{e.class}: #{e.message}" }
253
+ RedisErrors.report('Signal subscriber', e)
197
254
  sleep(1)
198
255
  retry
199
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
@@ -37,11 +37,18 @@ module Cogworker
37
37
  # define `#to_i` (`nil.to_i` happens to be `0` — coincidentally correct
38
38
  # for absent — but `false.to_i` raises `NoMethodError` outright, which is
39
39
  # exactly the bug this method exists to not have).
40
+ #
41
+ # Anything else (a Hash, a non-numeric String…) means no retries — the
42
+ # cautious reading for a job with side effects — rather than raising
43
+ # from the middle of recording its failure.
40
44
  def max_retries(job)
41
- case job['retry']
45
+ case (value = job['retry'])
42
46
  when false then 0
43
47
  when true, nil then DEFAULT_MAX_RETRY_ATTEMPTS
44
- else job['retry'].to_i
48
+ when Integer then value
49
+ when Float then value.to_i
50
+ when String then Integer(value, exception: false) || 0
51
+ else 0
45
52
  end
46
53
  end
47
54
 
@@ -75,12 +82,17 @@ module Cogworker
75
82
  # commands, a crash or dropped connection between the ZREM and the
76
83
  # LPUSH lost the job. The new payload is built in Ruby, not re-encoded
77
84
  # by Lua's cjson (which would round large integers in args).
85
+ # Present? Push, then remove — never remove first: a script stopped by
86
+ # an error keeps the writes made before it, so a ZREM followed by a
87
+ # failing LPUSH (the queue key holding the wrong type) lost the entry.
88
+ # The `cogworker:queues` listing is best-effort.
78
89
  CLAIM_AND_REQUEUE_SCRIPT = <<~LUA
79
- if redis.call("ZREM", KEYS[1], ARGV[1]) == 0 then
90
+ if not redis.call("ZSCORE", KEYS[1], ARGV[1]) then
80
91
  return 0
81
92
  end
82
- redis.call("SADD", KEYS[2], ARGV[2])
83
93
  redis.call("LPUSH", KEYS[3], ARGV[3])
94
+ redis.call("ZREM", KEYS[1], ARGV[1])
95
+ redis.pcall("SADD", KEYS[2], ARGV[2])
84
96
  return 1
85
97
  LUA
86
98
 
@@ -120,8 +132,31 @@ module Cogworker
120
132
  limit ? str[0, limit] : str
121
133
  end
122
134
 
135
+ # Never raises: an exception whose own `#message` raises (a broken
136
+ # custom exception class) used to escape from the middle of recording
137
+ # its failure — and, the job then counting as not-yet-run, it was handed
138
+ # back and re-run about once a second, forever.
123
139
  def error_message(error)
124
140
  safe_string(error.message, ERROR_MESSAGE_LIMIT)
141
+ rescue Exception # rubocop:disable Lint/RescueException
142
+ "#<#{error.class}>"
143
+ end
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
125
160
  end
126
161
 
127
162
  UNPARSEABLE_CLASS = '(unparseable)'
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module Cogworker
6
+ # Finds this gem's Redis keys holding the wrong type of value — which
7
+ # only a manual write or data corruption produces, but which then breaks
8
+ # every command on that key, deterministically: a queue that can't be
9
+ # pushed to, a `dead` set nothing can be filed in, a registry that fails
10
+ # every beat — and moves each such key out of the way, to
11
+ # `cogworker:quarantine:<key>:<time>:<random>` (kept QUARANTINE_TTL for a human to
12
+ # look at), with an error in the log. Everything then carries on with a
13
+ # fresh, empty key of the right type. Run by every process's Scheduled
14
+ # poller at boot and every CHECK_INTERVAL.
15
+ module KeyGuard
16
+ CHECK_INTERVAL = 60
17
+ QUARANTINE_TTL = 30 * 24 * 60 * 60
18
+ QUARANTINE_PREFIX = 'cogworker:quarantine:'
19
+
20
+ FIXED = {
21
+ RedisKeys::SCHEDULE => 'zset', RedisKeys::RETRY => 'zset', RedisKeys::DEAD => 'zset',
22
+ RedisKeys::QUEUES => 'set', RedisKeys::PROCESSES => 'set', RedisKeys::PAUSED_QUEUES => 'set',
23
+ RedisKeys::IN_PROGRESS_IDENTITIES => 'set', RedisKeys::LAST_BEAT => 'hash',
24
+ RedisKeys::REPEAT_ORPHANS => 'list', RedisKeys::UNSETTLED => 'list', RedisKeys::PERIODIC_SCHEDULE => 'hash',
25
+ RedisKeys::LIVE_QUEUES => 'zset',
26
+ RedisKeys::PERIODIC_DISABLED => 'set', RedisKeys::STATS_PROCESSED => 'string',
27
+ RedisKeys::STATS_FAILED => 'string'
28
+ }.freeze
29
+
30
+ # KEYS[1] = the key, KEYS[2] = its quarantine name; ARGV[1] = expected
31
+ # type, ARGV[2] = quarantine TTL. Re-checked here, atomically with the
32
+ # move, so a key fixed (or recreated) in between is left alone.
33
+ QUARANTINE_SCRIPT = <<~LUA
34
+ local actual = redis.call("TYPE", KEYS[1])["ok"]
35
+ if actual == "none" or actual == ARGV[1] then
36
+ return 0
37
+ end
38
+ redis.call("RENAME", KEYS[1], KEYS[2])
39
+ redis.call("EXPIRE", KEYS[2], ARGV[2])
40
+ return 1
41
+ LUA
42
+
43
+ module_function
44
+
45
+ # Returns the keys quarantined.
46
+ def check(queues: Cogworker.config.queues)
47
+ expected = expected_types(queues)
48
+ actual = Cogworker.config.redis do |c|
49
+ c.pipelined { |p| expected.each_key { |key| p.type(key) } }
50
+ end
51
+ expected.keys.zip(actual).filter_map do |key, type|
52
+ next if type == 'none' || type == expected[key]
53
+
54
+ key if quarantine(key, expected[key], type)
55
+ end
56
+ end
57
+
58
+ # The fixed keys, plus the per-queue and per-process ones currently
59
+ # listed in them (only where the listing itself is readable).
60
+ def expected_types(queues)
61
+ types = FIXED.dup
62
+ queue_names = Array(queues) | readable_members(RedisKeys::QUEUES)
63
+ queue_names.each { |q| types[RedisKeys.queue(q)] = 'list' }
64
+ readable_members(RedisKeys::IN_PROGRESS_IDENTITIES).each { |id| types[RedisKeys.in_progress(id)] = 'list' }
65
+ readable_members(RedisKeys::PROCESSES).each do |id|
66
+ types[RedisKeys.process(id)] = 'hash'
67
+ types[RedisKeys.workers(id)] = 'hash'
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
73
+ types
74
+ end
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
+
104
+ def readable_members(set)
105
+ Cogworker.config.redis { |c| c.smembers(set) }
106
+ rescue Redis::CommandError
107
+ [] # itself of the wrong type: quarantined this round, listed again next
108
+ end
109
+
110
+ def quarantine(key, expected, actual)
111
+ # Unique per move: a second quarantine of the same key within the same
112
+ # second must not overwrite (RENAME) the first one's copy.
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)}"
115
+ moved = Cogworker.config.redis do |c|
116
+ LuaScript.run(c, QUARANTINE_SCRIPT, keys: [key, target], argv: [expected, QUARANTINE_TTL])
117
+ end
118
+ return false unless moved == 1
119
+
120
+ prune_copies(key)
121
+ Cogworker.logger.error do
122
+ "#{key} held a #{actual}, not a #{expected}: moved to #{target} (kept #{QUARANTINE_TTL / 86_400} days)"
123
+ end
124
+ true
125
+ end
126
+ end
127
+ end
@@ -13,14 +13,15 @@ module Cogworker
13
13
  @manager = Manager.new(config)
14
14
  @scheduled = Scheduled.new(@manager)
15
15
  @heartbeat = Heartbeat.new(@manager)
16
- # The first tick waits for the first orphan check: after the whole
16
+ # The first tick waits for the first orphan check that really
17
+ # completed (Scheduled#ready_for_ticker?): after the whole
17
18
  # fleet was down longer than a running `until_executed` job's lock
18
19
  # lives, the ticker would otherwise claim that entry's next slot before
19
20
  # recovery had requeued the interrupted run (and re-taken its lock) —
20
21
  # two runs at once.
21
22
  @ticker = Periodic::Ticker.new(
22
23
  @manager, config.periodic_manager.entries, catch_up: config.periodic_catch_up,
23
- ready: -> { @scheduled.recovered_once? }
24
+ ready: -> { @scheduled.ready_for_ticker? }
24
25
  )
25
26
  @signal_queue = ::Queue.new
26
27
  end
@@ -44,6 +45,9 @@ module Cogworker
44
45
 
45
46
  def stop!
46
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) } }
47
51
  @scheduled.stop!
48
52
  @ticker.stop!
49
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
@@ -7,6 +7,17 @@ module Cogworker
7
7
  def initialize(config = Cogworker.config)
8
8
  @config = config
9
9
  @fetch_mutex = Mutex.new
10
+ # This process's own record of its jobs (raw payloads, as fetched):
11
+ # running right now (a count — two copies of one payload can run at
12
+ # once), and finished but not yet settled in Redis — `:ack` (the ack
13
+ # failed) or `[set, score, payload]` (the job ran and failed, and
14
+ # filing it in retry/dead failed too). In memory on purpose:
15
+ # ReliableFetch.reconcile trusts it over anything in Redis, which may
16
+ # be what's failing (or what lost its last writes in a failover).
17
+ @jobs_mutex = Mutex.new
18
+ @running_jobs = Hash.new(0)
19
+ @performing_jobs = Hash.new(0) # the subset still inside `perform`
20
+ @pending_settlements = {}
10
21
  @processors = Array.new(config.concurrency) { Processor.new(self) }
11
22
  @quiet = false
12
23
  @stopping = false
@@ -63,6 +74,51 @@ module Cogworker
63
74
  @stopping
64
75
  end
65
76
 
77
+ def job_started(raw)
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 }
96
+ end
97
+
98
+ def job_finished(raw)
99
+ @jobs_mutex.synchronize do
100
+ @running_jobs[raw] -= 1
101
+ @running_jobs.delete(raw) unless @running_jobs[raw].positive?
102
+ end
103
+ end
104
+
105
+ def running_jobs
106
+ @jobs_mutex.synchronize { @running_jobs.keys }
107
+ end
108
+
109
+ # `how`: :ack, or [set, score, payload] — see ReliableFetch.settle.
110
+ def settle_later(raw, how)
111
+ @jobs_mutex.synchronize { @pending_settlements[raw] = how }
112
+ end
113
+
114
+ def settled(raw)
115
+ @jobs_mutex.synchronize { @pending_settlements.delete(raw) }
116
+ end
117
+
118
+ def pending_settlements
119
+ @jobs_mutex.synchronize { @pending_settlements.dup }
120
+ end
121
+
66
122
  def busy_count
67
123
  @busy_mutex.synchronize { @busy_count }
68
124
  end
@@ -109,8 +165,27 @@ module Cogworker
109
165
  end
110
166
  end
111
167
 
168
+ # Jobs that finished but aren't settled in Redis yet are still on the
169
+ # in-progress list. One more try at settling them first; then `except:`
170
+ # drops the ones only waiting for their ack (which is what dropping
171
+ # them is), in the same atomic step as requeueing the rest — a separate
172
+ # ack pass that failed used to leave them to be requeued and run again.
173
+ # Ones that couldn't be filed in retry/dead even now are handed over to
174
+ # UNSETTLED for any live process to file later (ReliableFetch.park_unsettled)
175
+ # — never requeued: they already ran. Any that can't even be handed
176
+ # over are left on the list (`keep:`): orphan recovery requeues them
177
+ # once this process is gone, at most `max_orphanings` times.
112
178
  def requeue_unfinished
113
- moved = fetch_class.requeue_in_progress(Cogworker.identity)
179
+ keep = []
180
+ if fetch_class == ReliableFetch
181
+ ReliableFetch.settle_pending(Cogworker.identity, pending_settlements) { |raw| settled(raw) }
182
+ unfiled = pending_settlements.reject { |_raw, how| how == :ack }
183
+ keep = ReliableFetch.park_unsettled(Cogworker.identity, unfiled)
184
+ (unfiled.keys - keep).each { |raw| settled(raw) }
185
+ end
186
+ to_ack = pending_settlements.select { |_raw, how| how == :ack }.keys
187
+ moved = fetch_class.requeue_in_progress(Cogworker.identity, except: to_ack, keep: keep)
188
+ to_ack.each { |raw| settled(raw) }
114
189
  Cogworker.logger.warn { "requeued #{moved} job(s) unfinished at shutdown" } if moved.positive?
115
190
  rescue StandardError => e
116
191
  Cogworker.logger.error { "Requeueing unfinished jobs failed: #{e.class}: #{e.message}" }
@@ -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])