cogworker 0.3.0 → 0.4.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.
@@ -17,12 +17,33 @@ module Cogworker
17
17
 
18
18
  CLAIM_SCRIPT = File.read(File.join(__dir__, 'claim.lua'))
19
19
 
20
- def initialize(manager, entries, catch_up: true)
20
+ # Undoes a won claim whose push then failed, so the slot isn't lost:
21
+ # puts `last_slot` back (only if it still is this slot — a later slot
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).
27
+ ROLLBACK_SCRIPT = <<~LUA
28
+ if redis.call("GET", KEYS[1]) == ARGV[1] then
29
+ if ARGV[2] == "" then
30
+ redis.call("SET", KEYS[1], tostring(tonumber(ARGV[1]) - 1))
31
+ else
32
+ redis.call("SET", KEYS[1], ARGV[2])
33
+ end
34
+ end
35
+ redis.call("DEL", KEYS[2])
36
+ return 1
37
+ LUA
38
+
39
+ def initialize(manager, entries, catch_up: true, ready: -> { true })
21
40
  @manager = manager
22
41
  @entries = entries
23
42
  @catch_up = catch_up
43
+ @ready = ready
24
44
  @last_checked_slot = {}
25
45
  @cron_cache = {}
46
+ @pending_rollbacks = []
26
47
  end
27
48
 
28
49
  def start!
@@ -57,12 +78,13 @@ module Cogworker
57
78
 
58
79
  # A failed tick is logged and retried next interval rather than ending
59
80
  # the thread — a single Redis blip used to silently stop every
60
- # periodic entry in this process for good. A slot whose claim raised
61
- # isn't recorded in `@last_checked_slot`, so the next tick retries it.
81
+ # periodic entry in this process for good. A slot whose claim or push
82
+ # raised isn't recorded in `@last_checked_slot` (and a failed push
83
+ # rolls its claim back — see #enqueue), so the next tick retries it.
62
84
  def run
63
85
  until @manager.stopping?
64
86
  begin
65
- tick unless @manager.quiet?
87
+ tick unless @manager.quiet? || !@ready.call
66
88
  rescue StandardError => e
67
89
  Cogworker.logger.error { "Periodic tick failed: #{e.class}: #{e.message}" }
68
90
  end
@@ -71,19 +93,21 @@ module Cogworker
71
93
  end
72
94
 
73
95
  def tick
96
+ retry_pending_rollbacks
74
97
  now = Time.now
75
98
  @entries.each do |entry|
76
99
  slot = cron_for(entry).previous_time(now).to_i
77
100
  next if @last_checked_slot[entry.pjid] == slot
78
101
 
79
102
  jid = SecureRandom.hex(12)
80
- enqueue(entry, slot, jid) if !disabled?(entry) && claim?(entry, slot, jid)
103
+ previous = claim(entry, slot, jid) unless disabled?(entry)
104
+ enqueue(entry, slot, jid, previous) if previous
81
105
  @last_checked_slot[entry.pjid] = slot
82
106
  end
83
107
  end
84
108
 
85
109
  # Web UI "Disable" (`Routes::Schedules`) — skips the claim/enqueue
86
- # step entirely, short-circuiting before `claim?` even runs, so
110
+ # step entirely, short-circuiting before `claim` even runs, so
87
111
  # neither `periodic:last_slot:<pjid>` nor the per-slot lock advance
88
112
  # while disabled: the Web UI's own "LastRun" column keeps showing the
89
113
  # last time it *actually* ran, not a due-but-skipped slot, and
@@ -93,6 +117,30 @@ module Cogworker
93
117
  # persisted last_slot) still advances either way, exactly as it
94
118
  # already did before this entry ever had a disabled state — it only
95
119
  # stops this same tick loop from re-evaluating the same slot twice.
120
+ # The usual reason a push fails — Redis unreachable — makes this fail
121
+ # too; it's then kept and retried at the start of every tick until it
122
+ # 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)
127
+ 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])
130
+ end
131
+ true
132
+ 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
136
+ end
137
+
138
+ def retry_pending_rollbacks
139
+ pending = @pending_rollbacks
140
+ @pending_rollbacks = []
141
+ pending.each { |args| rollback_claim(*args) }
142
+ end
143
+
96
144
  def disabled?(entry)
97
145
  Cogworker.config.redis { |c| c.sismember(RedisKeys::PERIODIC_DISABLED, entry.pjid) }
98
146
  end
@@ -101,16 +149,18 @@ module Cogworker
101
149
  @cron_cache[entry.pjid] ||= Fugit::Cron.parse(entry.cron)
102
150
  end
103
151
 
104
- def claim?(entry, slot, jid)
105
- return false if !@catch_up && priming_first_slot?(entry, slot)
152
+ # nil if this process didn't win the slot; otherwise the previous
153
+ # `last_slot` value ("" if none), for #rollback_claim.
154
+ def claim(entry, slot, jid)
155
+ return nil if !@catch_up && priming_first_slot?(entry, slot)
106
156
 
107
157
  result = Cogworker.config.redis do |c|
108
- c.eval(CLAIM_SCRIPT,
158
+ LuaScript.run(c, CLAIM_SCRIPT,
109
159
  keys: [RedisKeys.periodic_running(entry.pjid), RedisKeys.periodic_last_slot(entry.pjid),
110
160
  RedisKeys.periodic_lock(entry.pjid, slot)],
111
161
  argv: [slot, entry.unique.to_s, LOCK_TTL, jid, RunningLock.queued_ttl])
112
162
  end
113
- result == 1
163
+ result.is_a?(Array) ? result[1].to_s : nil
114
164
  end
115
165
 
116
166
  # With catch-up disabled, an entry's very first tick — ever, across
@@ -140,15 +190,19 @@ module Cogworker
140
190
  # Only undone here if the push didn't actually produce a job (raised,
141
191
  # or a client middleware swallowed it), so a job that never existed
142
192
  # can't hold the entry for the whole `RunningLock.queued_ttl`.
143
- def enqueue(entry, slot, jid)
193
+ def enqueue(entry, slot, jid, previous_slot)
144
194
  job = {
145
195
  'class' => entry.class_name, 'args' => entry.args, 'retry' => entry.retry,
146
196
  'periodic_pjid' => entry.pjid, 'periodic_slot' => slot, 'jid' => jid
147
197
  }
198
+ # Lets a requeue after a crash/shutdown re-take this run's lock
199
+ # (Periodic::RunningLock::RETAKE_FUNCTION, in both fetches' requeue scripts).
200
+ job['periodic_until_executed'] = true if entry.until_executed?
148
201
  pushed = begin
149
202
  Client.push(job)
150
203
  rescue StandardError
151
204
  RunningLock.release(entry.pjid, jid) if entry.until_executed?
205
+ rollback_claim(entry, slot, previous_slot)
152
206
  raise
153
207
  end
154
208
  return unless entry.until_executed?
@@ -6,9 +6,26 @@ require 'securerandom'
6
6
  module Cogworker
7
7
  # One thread of a Manager's pool: fetch -> register in-flight -> run the
8
8
  # server middleware chain around perform -> stats/retry -> deregister.
9
+ #
10
+ # What happens to the fetched job afterwards depends on how far it got —
11
+ # the one rule being that a job is never re-run because of a failure that
12
+ # happened *after* it ran:
13
+ # - failed before `perform` (registering it in `cogworker:workers`):
14
+ # `give_back` — straight back on its queue, it hasn't run yet;
15
+ # - ran (succeeded, or failed and was recorded in retry/dead): acknowledge.
16
+ # Everything after `perform` other than that one retry/dead write —
17
+ # stats, throughput, deregistering, lock extension, the attempts log —
18
+ # is best-effort: logged, never raised;
19
+ # - ran and failed, but the retry/dead write itself failed: `interrupt` —
20
+ # into `retry` after INTERRUPT_DELAY, counting `interrupted_count`, and
21
+ # into `dead` once that passes MAX_INTERRUPTS. Never straight back on
22
+ # the queue: a write that fails deterministically used to make that
23
+ # loop forever, re-running the job every second.
9
24
  class Processor
10
25
  ERROR_BACKOFF = 1 # seconds
11
26
  ACK_ATTEMPTS = 3
27
+ MAX_INTERRUPTS = 3
28
+ INTERRUPT_DELAY = 30 # seconds, times the interruption count
12
29
 
13
30
  attr_reader :thread, :tid
14
31
 
@@ -16,7 +33,6 @@ module Cogworker
16
33
  @manager = manager
17
34
  @busy = false
18
35
  @tid = SecureRandom.hex(6)
19
- @fetcher = manager.fetch_class.new(manager.queues)
20
36
  end
21
37
 
22
38
  def start!
@@ -54,23 +70,75 @@ module Cogworker
54
70
  return
55
71
  end
56
72
 
57
- work = @fetcher.retrieve_work
73
+ work = begin
74
+ fetcher.retrieve_work
75
+ rescue ReliableFetch::UnsupportedError => e
76
+ @manager.fall_back_to_basic_fetch!(e)
77
+ @fetcher = nil
78
+ return
79
+ end
58
80
  return unless work
59
81
 
60
82
  # Fetched after shutdown began (e.g. still blocked in BRPOP when
61
83
  # `Manager#stop!` requeued unfinished work, which it then popped right
62
84
  # back): hand it back rather than start it, since the process is about
63
85
  # to exit underneath it.
64
- return @fetcher.give_back(work) if @manager.stopping?
86
+ return fetcher.give_back(work) if @manager.stopping?
65
87
 
66
88
  @manager.processor_busy!
67
89
  @busy = true
90
+ disposition = :give_back
68
91
  begin
69
- execute(work)
92
+ disposition = execute(work)
70
93
  ensure
71
94
  @busy = false
72
95
  @manager.processor_idle!
73
- acknowledge(work)
96
+ settle(work, disposition)
97
+ end
98
+ end
99
+
100
+ def settle(work, disposition)
101
+ case disposition
102
+ when :acknowledge then acknowledge(work)
103
+ when :give_back then give_back(work)
104
+ else interrupt(work, *disposition.drop(1))
105
+ end
106
+ end
107
+
108
+ # Only for a job that hasn't run yet (see the class comment). If even
109
+ # this fails, it stays on the in-progress list, which `Manager#stop!` /
110
+ # orphan recovery / `ReliableFetch.reconcile` requeue later.
111
+ def give_back(work)
112
+ fetcher.give_back(work)
113
+ rescue StandardError => e
114
+ Cogworker.logger.error do
115
+ "couldn't hand back jid=#{jid_of(work)} (left in progress): #{e.class}: #{e.message}"
116
+ end
117
+ end
118
+
119
+ # The job ran and failed, but recording that in retry/dead failed. Built
120
+ # from the original payload (always serializable — it came from JSON)
121
+ # plus the failure fields, never from the in-memory job hash: whatever
122
+ # made that one fail to serialize must not do it again here.
123
+ def interrupt(work, job, error)
124
+ payload = JSON.parse(work.raw_job)
125
+ count = payload['interrupted_count'].to_i + 1
126
+ payload.merge!('interrupted_count' => count, 'retry_count' => job['retry_count'],
127
+ 'error_class' => error.class.name, 'error_message' => JobUtil.error_message(error),
128
+ 'failed_at' => Time.now.to_f)
129
+ set, score = if count > MAX_INTERRUPTS
130
+ [RedisKeys::DEAD, Time.now.to_f]
131
+ else
132
+ [RedisKeys::RETRY, Time.now.to_f + (INTERRUPT_DELAY * count)]
133
+ 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})"
138
+ end
139
+ rescue StandardError => e
140
+ Cogworker.logger.error do
141
+ "couldn't record or interrupt jid=#{jid_of(work)} (left in progress): #{e.class}: #{e.message}"
74
142
  end
75
143
  end
76
144
 
@@ -82,7 +150,7 @@ module Cogworker
82
150
  attempts = 0
83
151
  begin
84
152
  attempts += 1
85
- @fetcher.acknowledge(work)
153
+ fetcher.acknowledge(work)
86
154
  rescue StandardError => e
87
155
  if attempts < ACK_ATTEMPTS
88
156
  sleep(0.1 * attempts)
@@ -101,53 +169,80 @@ module Cogworker
101
169
  '?'
102
170
  end
103
171
 
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).
104
175
  def execute(work)
105
176
  job = parse_job(work)
106
- return unless job
177
+ return :acknowledge unless job
107
178
 
108
179
  register_in_workers(work.queue, job)
109
180
  Cogworker.logger.info { "start: #{job['class']} jid=#{job['jid']}" }
110
-
111
181
  begin
112
- # `build_worker` itself can raise (most commonly `NameError`, e.g. a
113
- # class the caller registered/enqueued but never actually defined —
114
- # deliberately still routed through the *same* middleware chain
115
- # below rather than caught here directly: `History::Middleware`/
116
- # `Status::ServerMiddleware` (and any custom server middleware)
117
- # only ever see a job by wrapping this `chain.invoke` call, so a
118
- # resolution failure caught out here, before `invoke` ever runs,
119
- # would never reach them — this job would fail/retry/die with no
120
- # History entry and no status update at all, silently. Catching it
121
- # here and re-raising it as the chain's own "final block" gives
122
- # every middleware the same crack at observing this failure as any
123
- # perform-time one, with `worker` as `nil` in that case (every
124
- # built-in server middleware ignores the `worker` arg entirely
125
- # already; a custom one that needs a real instance simply can't act
126
- # on this failure either way — there's no worker to act on).
127
- resolution_error = nil
128
- worker = begin
129
- build_worker(job)
130
- rescue Exception => e # rubocop:disable Lint/RescueException
131
- resolution_error = e
132
- nil
133
- end
182
+ error = run_job(work, job)
183
+ error ? finish_failure(job, error) : finish_success(job)
184
+ ensure
185
+ best_effort('deregister') { deregister_from_workers }
186
+ end
187
+ end
188
+
189
+ # The job's own outcome: nil, or what it (or its middleware) raised.
190
+ def run_job(work, job)
191
+ # `build_worker` itself can raise (most commonly `NameError`, e.g. a
192
+ # class the caller registered/enqueued but never actually defined —
193
+ # deliberately still routed through the *same* middleware chain
194
+ # below rather than caught here directly: `History::Middleware`/
195
+ # `Status::ServerMiddleware` (and any custom server middleware)
196
+ # only ever see a job by wrapping this `chain.invoke` call, so a
197
+ # resolution failure caught out here, before `invoke` ever runs,
198
+ # would never reach them — this job would fail/retry/die with no
199
+ # History entry and no status update at all, silently. Catching it
200
+ # here and re-raising it as the chain's own "final block" gives
201
+ # every middleware the same crack at observing this failure as any
202
+ # perform-time one, with `worker` as `nil` in that case (every
203
+ # built-in server middleware ignores the `worker` arg entirely
204
+ # already; a custom one that needs a real instance simply can't act
205
+ # on this failure either way — there's no worker to act on).
206
+ resolution_error = nil
207
+ worker = begin
208
+ build_worker(job)
209
+ rescue Exception => e # rubocop:disable Lint/RescueException
210
+ resolution_error = e
211
+ nil
212
+ end
134
213
 
135
- Cogworker.config.server_chain.invoke(worker, job, work.queue) do
136
- raise resolution_error if resolution_error
214
+ Cogworker.config.server_chain.invoke(worker, job, work.queue) do
215
+ raise resolution_error if resolution_error
137
216
 
138
- worker.perform(*job['args'])
139
- end
217
+ worker.perform(*job['args'])
218
+ end
219
+ nil
220
+ rescue Exception => e # rubocop:disable Lint/RescueException
221
+ e
222
+ end
223
+
224
+ def finish_success(job)
225
+ best_effort('stats') do
140
226
  Cogworker.config.redis { |c| c.incr(RedisKeys::STATS_PROCESSED) }
141
227
  Throughput.record('processed')
142
- Cogworker.logger.info { "done: #{job['class']} jid=#{job['jid']}" }
143
- rescue Exception => e # rubocop:disable Lint/RescueException
228
+ end
229
+ Cogworker.logger.info { "done: #{job['class']} jid=#{job['jid']}" }
230
+ :acknowledge
231
+ end
232
+
233
+ def finish_failure(job, error)
234
+ best_effort('stats') do
144
235
  Cogworker.config.redis { |c| c.incr(RedisKeys::STATS_FAILED) }
145
236
  Throughput.record('failed')
146
- route_failure(job, e)
147
- Cogworker.logger.warn { "fail: #{job['class']} jid=#{job['jid']}: #{e.class}: #{e&.message}" }
148
237
  end
149
- ensure
150
- deregister_from_workers
238
+ Cogworker.logger.warn { "fail: #{job['class']} jid=#{job['jid']}: #{error.class}: #{JobUtil.error_message(error)}" }
239
+ route_failure(job, error) ? :acknowledge : [:interrupt, job, error]
240
+ end
241
+
242
+ def best_effort(what)
243
+ yield
244
+ rescue StandardError => e
245
+ Cogworker.logger.error { "#{what} failed (ignored): #{e.class}: #{e.message}" }
151
246
  end
152
247
 
153
248
  # A payload that isn't a JSON object goes straight to `dead`
@@ -174,6 +269,12 @@ module Cogworker
174
269
  Cogworker.logger.error { "unparseable job on queue #{work.queue} moved to dead: #{error.message}" }
175
270
  end
176
271
 
272
+ # Built on first use, not in the constructor (see Manager#fetch_class),
273
+ # and rebuilt after a fall-back to BasicFetch.
274
+ def fetcher
275
+ @fetcher ||= @manager.fetch_class.new(@manager.queues, stopping: -> { @manager.stopping? })
276
+ end
277
+
177
278
  def build_worker(job)
178
279
  klass = Object.const_get(job['class'])
179
280
  worker = klass.new
@@ -181,24 +282,35 @@ module Cogworker
181
282
  worker
182
283
  end
183
284
 
285
+ # True once the failure is recorded in retry/dead — the one write that
286
+ # must happen; everything after it is best-effort. False if that write
287
+ # failed (`finish_failure` then interrupts the job instead).
184
288
  def route_failure(job, error)
185
289
  job['error_class'] = error.class.name
186
- job['error_message'] = error.message.to_s[0, 10_000]
290
+ job['error_message'] = JobUtil.error_message(error)
187
291
  job['failed_at'] ||= Time.now.to_f
188
292
 
189
293
  max_retries = JobUtil.max_retries(job)
190
294
  new_count = job['retry_count'].to_i + 1
191
295
  job['retry_count'] = new_count
296
+ retrying = new_count <= max_retries
297
+ delay = retry_delay(new_count) if retrying
298
+
299
+ begin
300
+ payload = JSON.generate(job)
301
+ 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)
303
+ end
304
+ rescue StandardError => e
305
+ Cogworker.logger.error { "couldn't record failure of jid=#{job['jid']}: #{e.class}: #{e.message}" }
306
+ return false
307
+ end
192
308
 
193
- if new_count <= max_retries
194
- delay = retry_delay(new_count)
195
- Cogworker.config.redis { |c| c.zadd(RedisKeys::RETRY, Time.now.to_f + delay, JSON.generate(job)) }
196
- extend_locks_for_retry(job, delay)
197
- Attempts.record(job['jid'], attempt: new_count, error: error, outcome: 'retrying')
198
- else
199
- Cogworker.config.redis { |c| c.zadd(RedisKeys::DEAD, Time.now.to_f, JSON.generate(job)) }
200
- Attempts.record(job['jid'], attempt: new_count, error: error, outcome: 'dead')
309
+ best_effort('lock extension') { extend_locks_for_retry(job, delay) } if retrying
310
+ best_effort('attempts log') do
311
+ Attempts.record(job['jid'], attempt: new_count, error: error, outcome: retrying ? 'retrying' : 'dead')
201
312
  end
313
+ true
202
314
  end
203
315
 
204
316
  # A job waiting out its retry backoff still holds its `until_executed`
@@ -10,6 +10,13 @@ module Cogworker
10
10
  module RedisKeys
11
11
  QUEUE_PREFIX = 'cogworker:queue:'
12
12
  IN_PROGRESS_PREFIX = 'cogworker:inprogress:'
13
+ # Every identity that has (or may have) an in-progress list — what
14
+ # orphan recovery walks instead of SCANning the whole keyspace.
15
+ IN_PROGRESS_IDENTITIES = 'cogworker:inprogress_identities'
16
+ # identity => Redis server time (epoch seconds) of its last heartbeat.
17
+ # No TTL, unlike `process(identity)`: it's what tells "dead for a
18
+ # while" apart from "missed a few beats" (Config#orphan_threshold).
19
+ LAST_BEAT = 'cogworker:last_beat'
13
20
  QUEUES = 'cogworker:queues'
14
21
  SCHEDULE = 'cogworker:schedule'
15
22
  RETRY = 'cogworker:retry'
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cogworker
4
+ class ReliableFetch
5
+ # Raised by ReliableFetch#retrieve_work when the server turns out not to
6
+ # know LMOVE (Redis < 6.2) even though the version check at boot let
7
+ # `:reliable` through (e.g. Redis wasn't reachable then). The Processor
8
+ # reacts by switching the whole Manager over to BasicFetch.
9
+ class UnsupportedError < StandardError; end
10
+ end
11
+ end