cogworker 0.6.0 → 0.7.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2a2d6b5721f03a71d8a74d376311c047175b71f950d51f227c60598dad74dce2
4
- data.tar.gz: d6f10624306849c6e32e8d7fa0211db6c2b947d78d53fec10b79039bfec57645
3
+ metadata.gz: f0b88ae2031cced0f33c8de66892dbd952e7aba6ed64ad1fba49719bb335698d
4
+ data.tar.gz: 6cd9c81f131107a8b04bb170dbedb29898899d248b900bfcf4fcbeebf69463bf
5
5
  SHA512:
6
- metadata.gz: 97347577fddf8a44cc0c5df6b6aafd5a60c285f88f58f7c09193f8a1031d5bf9dc0381e2708a9d591ff81dde7781b31be6a72f7d7567b9f2a0a8b74fb0de2658
7
- data.tar.gz: 84ea3a607e75d78b23dab9919c33595ade3ac2382c2c253896a6e3b4df7664d8369fbd1b81a387eaaa9ae95fe3867dd8739ce7d757a0e576047f51321e78cd5e
6
+ metadata.gz: 867b3c3cc51e6ee56f2fa8e7433b52598b366873ba9609ac9cfa81904bb1e9f0fdb0c35b6816bee0a7605c6536c0a88749e77f972e8d611c7fd3df726772b5fd
7
+ data.tar.gz: 1bf378d2c4e52a6034d47c2239a21539ae722e3877c015585ef446cfff6c0456c893c071e088b9c857759ac04849a7a481f804b816dafea85dc5e0fce46fe59a
data/README.md CHANGED
@@ -266,8 +266,99 @@ real worker would receive Strings instead of Symbols.
266
266
  ### Retries and Dead
267
267
 
268
268
  A failed job moves to the Retry set with a growing delay
269
- (`count**4 + 15 + jitter` seconds). When it runs out of attempts, it moves to
270
- Dead. You can put it back on its queue by hand from the Web UI.
269
+ (`count**4 + 15 + jitter` seconds, `count` being the retry about to happen,
270
+ 1 for the first). `retry:` sets the budget: `true` or unset gives 25
271
+ retries, an integer that many, `false` or `0` none. When it runs out of
272
+ retries, it moves to Dead. You can put it back on its queue by hand from the
273
+ Web UI.
274
+
275
+ #### Custom delay: `cogworker_retry_in`
276
+
277
+ ```ruby
278
+ class SendEventJob
279
+ include Cogworker::Worker
280
+ cogworker_options retry: 10
281
+
282
+ cogworker_retry_in do |count, exception, job|
283
+ next :kill if exception.is_a?(PermanentError)
284
+
285
+ 60 * (2**count)
286
+ end
287
+ end
288
+ ```
289
+
290
+ `count` is the number of retries made before this failure (0 on the first
291
+ one), `exception` what the attempt raised, `job` a copy of the job hash (the
292
+ block may take fewer parameters). It returns:
293
+
294
+ | Value | Effect |
295
+ |---|---|
296
+ | Integer / Float ≥ 0 | retry after that many seconds, no jitter added; 0 means on the next poll |
297
+ | `nil` | the default delay |
298
+ | `:kill` | straight to Dead, retries left or not; the death hooks run |
299
+ | `:discard` | dropped: neither Retry nor Dead, no death hooks; counted as failed, its unique locks released |
300
+ | anything else | the default delay, with a warning in the log |
301
+
302
+ If the block raises, the default delay is used and the error is logged. It
303
+ isn't called for a failure that is the last one anyway (no retry left, or
304
+ `retry: false`), nor for a job whose class can't be loaded. It's asked as
305
+ soon as `perform` raises, before any server middleware sees the error — so
306
+ it also runs when a middleware then swallows that error; keep it free of
307
+ side effects. A delay is never capped (one over a year is logged as a
308
+ likely mistake), and the job's `until_executed` lock is extended by it, so
309
+ the lock never runs out before the retry — however long that is. If
310
+ recording the failure itself fails (Redis trouble), the job is retried
311
+ after a fixed `30 s × n` instead, whatever the hook said. The hook is
312
+ inherited, so a base class of your own (`class ApplicationJob`) is the place
313
+ for an app-wide policy. The Web UI's retry timeline shows `:kill` and
314
+ `:discard` as such.
315
+
316
+ #### Death hooks: `cogworker_retries_exhausted` and `death_handlers`
317
+
318
+ ```ruby
319
+ class SendEventJob
320
+ include Cogworker::Worker
321
+
322
+ cogworker_retries_exhausted do |job, exception|
323
+ Metrics.increment('events.lost')
324
+ end
325
+ end
326
+
327
+ Cogworker.configure_server do |config|
328
+ config.death_handlers << ->(job, exception) { ErrorTracker.notify(exception, job) }
329
+ end
330
+ ```
331
+
332
+ When a job lands in Dead for good, its class's `cogworker_retries_exhausted`
333
+ runs first, then every `death_handlers` entry in order. `job` is the entry
334
+ as stored in Dead (with `error_class`, `error_message`, `retry_count`,
335
+ `failed_at`); each hook gets its own copy, so changing it changes nothing.
336
+
337
+ | How the job died | Class hook | `death_handlers` |
338
+ |---|---|---|
339
+ | out of retries, or `retry: false` / `0` | yes | yes |
340
+ | `cogworker_retry_in` returned `:kill` | yes | yes |
341
+ | its process kept dying while running it (`config.max_orphanings`) | yes, with a `Cogworker::JobOrphanedError` | yes |
342
+ | its failure was filed later, by a reconcile pass | yes, with a `Cogworker::JobFailedError` carrying the original message (the original class is in `job['error_class']`) | yes |
343
+ | its class can't be loaded (renamed, removed) | no | yes |
344
+ | `cogworker_retry_in` returned `:discard` | no | no |
345
+ | an unparseable payload | no | no |
346
+ | deleted or retried from the Web UI | no | no |
347
+
348
+ The hooks run only after the job is safely in Dead, at most once per death:
349
+ on the reconcile paths, only the process that actually filed it runs them.
350
+ They run synchronously, on the processor thread (once the job is
351
+ acknowledged, before the thread takes the next one) or on the reconcile
352
+ thread, with no timeout of their own — **keep them quick**. One that raises
353
+ is logged and doesn't stop the others, nor the job's ack or lock release.
354
+
355
+ A hook can be skipped or cut short, never repeated: a process that crashes
356
+ right after the write never runs it, and on shutdown a hook still running
357
+ when the drain timeout (25 s) is up may be cut off as the process exits.
358
+ The job itself is safe either way — already in Dead and acknowledged, so it
359
+ won't run again. `Testing.inline!` raises the job's error to the caller as before
360
+ and runs no hooks; test a hook directly through
361
+ `SomeJob.cogworker_retry_in_block` / `SomeJob.cogworker_retries_exhausted_block`.
271
362
 
272
363
  ### Unique jobs
273
364
 
@@ -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, :max_orphanings, :periodic_replace
12
+ :orphan_threshold, :max_orphanings, :periodic_replace, :death_handlers
13
13
  attr_accessor :concurrency, :queues, :periodic_catch_up, :unique_lock_ttl
14
14
 
15
15
  def initialize
@@ -57,6 +57,10 @@ module Cogworker
57
57
  # that crashed is counted, so keep this above the number of crashes an
58
58
  # innocent job might plausibly sit through.
59
59
  @max_orphanings = 3
60
+ # Callables `(job, exception)` run, after the job class's own
61
+ # `cogworker_retries_exhausted`, whenever a job lands in dead for good
62
+ # (DeathNotifier). `config.death_handlers << ->(job, e) { ... }`.
63
+ @death_handlers = []
60
64
  register_default_middleware
61
65
  end
62
66
 
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Cogworker
6
+ # Runs the death hooks for a job that has just been written to dead for
7
+ # good: the job class's own `cogworker_retries_exhausted`, then every
8
+ # `config.death_handlers` entry, in order. Called only by whoever's write
9
+ # actually put it there (so at most once per death), after that write —
10
+ # and, from a processor, only once the job is acknowledged, so a slow
11
+ # hook can't leave it on the in-progress list to be requeued and re-run.
12
+ #
13
+ # Each hook gets its own copy of the job (as stored in dead) and runs on
14
+ # its own: one that raises is logged and never stops the others, nor the
15
+ # ack / lock releases / reconcile pass around the call. Synchronous, with
16
+ # no timeout — a hook should be quick.
17
+ module DeathNotifier
18
+ module_function
19
+
20
+ def notify(job, exception)
21
+ hook = job_class(job['class'])&.cogworker_retries_exhausted_block
22
+ run('cogworker_retries_exhausted', job) { |copy| JobUtil.call_hook(hook, copy, exception) } if hook
23
+ Cogworker.config.death_handlers.each do |handler|
24
+ run('death handler', job) { |copy| JobUtil.call_hook(handler, copy, exception) }
25
+ end
26
+ end
27
+
28
+ # A dead entry filed from its stored record (no exception object left).
29
+ def notify_failed(payload)
30
+ job = parse(payload)
31
+ notify(job, JobFailedError.new(job['error_message'].to_s)) if job
32
+ end
33
+
34
+ def notify_orphaned(job)
35
+ notify(job, JobOrphanedError.new("orphaned more than #{Cogworker.config.max_orphanings} times"))
36
+ end
37
+
38
+ def run(what, job)
39
+ yield JobUtil.deep_copy(job)
40
+ rescue Exception => e # rubocop:disable Lint/RescueException -- user code, isolated like `perform`
41
+ Cogworker.logger.error do
42
+ "#{what} failed for jid=#{job['jid']} (ignored): #{e.class}: #{JobUtil.error_message(e)}"
43
+ end
44
+ end
45
+
46
+ # nil for a class that can't be loaded (renamed, removed, never defined)
47
+ # or doesn't use the job DSL: only `death_handlers` run then.
48
+ def job_class(name)
49
+ return nil unless name.is_a?(String) && !name.empty?
50
+
51
+ klass = Object.const_get(name)
52
+ klass.respond_to?(:cogworker_retries_exhausted_block) ? klass : nil
53
+ rescue StandardError
54
+ nil
55
+ end
56
+
57
+ def parse(payload)
58
+ job = JSON.parse(payload)
59
+ job.is_a?(Hash) ? job : nil
60
+ rescue JSON::ParserError, TypeError
61
+ nil
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cogworker
4
+ # What becomes of a failed attempt: `retry` (after `delay` seconds — nil
5
+ # for the default backoff), `dead` or `discard`. Made once per failure:
6
+ # by the job class's `cogworker_retry_in` hook where it has one, otherwise
7
+ # by the retry budget and the default backoff.
8
+ #
9
+ # `Processor` makes it inside the middleware chain, the moment `perform`
10
+ # raises, and (when a hook took part) stamps it on the job as
11
+ # `failure_outcome` so `JobUtil.terminal_failure?` — and with it Status,
12
+ # UniqueJobs and Periodic middleware — gives the same answer as the
13
+ # retry/dead write. The stamp never reaches a stored entry.
14
+ class FailureDecision
15
+ OUTCOMES = %w[retry dead discard].freeze
16
+ # Used as given, never capped — but a delay past this is most likely a
17
+ # unit mix-up (ms for s), and it keeps an `until_executed` lock that long.
18
+ LONG_DELAY = 365 * 24 * 60 * 60
19
+
20
+ attr_reader :outcome, :delay
21
+
22
+ class << self
23
+ # `for`, stamped on the job as `failure_outcome` when the class's hook
24
+ # took part (see the class comment). nil if deciding itself failed.
25
+ def decide!(job, error, klass)
26
+ decision = self.for(job, error, klass)
27
+ job['failure_outcome'] = decision.outcome if decision.hooked?
28
+ decision
29
+ rescue StandardError => e
30
+ Cogworker.logger.error { "couldn't decide the fate of jid=#{job['jid']}: #{e.class}: #{e.message}" }
31
+ nil
32
+ end
33
+
34
+ # `klass` — the job's class, or nil when it couldn't be resolved (no
35
+ # hook then). The hook isn't asked about a failure that is the last
36
+ # one anyway: there's no retry to time.
37
+ def for(job, error, klass)
38
+ count = job['retry_count'].to_i
39
+ return new('dead') if count + 1 > JobUtil.max_retries(job)
40
+
41
+ hook = klass.respond_to?(:cogworker_retry_in_block) ? klass.cogworker_retry_in_block : nil
42
+ return new('retry') unless hook
43
+
44
+ from_hook(hook, job, error, count)
45
+ end
46
+
47
+ # Grows with the retry count, with jitter to avoid a thundering herd
48
+ # of retries all landing on the same second.
49
+ def default_delay(count)
50
+ (count**4) + 15 + (rand(30) * (count + 1))
51
+ end
52
+
53
+ private
54
+
55
+ def from_hook(hook, job, error, count)
56
+ value = JobUtil.call_hook(hook, count, error, JobUtil.deep_copy(job))
57
+ case value
58
+ when nil then new('retry', hooked: true)
59
+ when :kill then new('dead', killed: true, hooked: true)
60
+ when :discard then new('discard', hooked: true)
61
+ else
62
+ if valid_delay?(value)
63
+ warn_long_delay(job, value) if value > LONG_DELAY
64
+ return new('retry', value, hooked: true)
65
+ end
66
+
67
+ Cogworker.logger.warn do
68
+ "cogworker_retry_in of #{job['class']} returned #{value.inspect} for jid=#{job['jid']}; " \
69
+ 'using the default backoff'
70
+ end
71
+ new('retry', hooked: true)
72
+ end
73
+ rescue Exception => e # rubocop:disable Lint/RescueException -- user code, isolated like `perform`
74
+ Cogworker.logger.error do
75
+ "cogworker_retry_in of #{job['class']} raised for jid=#{job['jid']} (using the default backoff): " \
76
+ "#{e.class}: #{JobUtil.error_message(e)}"
77
+ end
78
+ new('retry', hooked: true)
79
+ end
80
+
81
+ def warn_long_delay(job, value)
82
+ Cogworker.logger.warn do
83
+ "cogworker_retry_in of #{job['class']} returned #{value}s (over a year) for jid=#{job['jid']}; " \
84
+ 'using it as given'
85
+ end
86
+ end
87
+
88
+ def valid_delay?(value)
89
+ (value.is_a?(Integer) || (value.is_a?(Float) && value.finite?)) && value >= 0
90
+ end
91
+ end
92
+
93
+ def initialize(outcome, delay = nil, killed: false, hooked: false)
94
+ @outcome = outcome
95
+ @delay = delay
96
+ @killed = killed
97
+ @hooked = hooked
98
+ end
99
+
100
+ def retry?
101
+ outcome == 'retry'
102
+ end
103
+
104
+ def dead?
105
+ outcome == 'dead'
106
+ end
107
+
108
+ def discard?
109
+ outcome == 'discard'
110
+ end
111
+
112
+ # Whether the job class's hook took part (only then is the decision
113
+ # stamped on the job: without one, `terminal_failure?` already agrees).
114
+ def hooked?
115
+ @hooked
116
+ end
117
+
118
+ # Ended before its retries ran out: `:kill`, or `:discard`.
119
+ def cut_short?
120
+ @killed || discard?
121
+ end
122
+
123
+ # The outcome as the attempts log (and the Web UI's timeline) shows it.
124
+ def attempt_outcome
125
+ return 'retrying' if retry?
126
+ return 'discarded' if discard?
127
+
128
+ @killed ? 'killed' : 'dead'
129
+ end
130
+ end
131
+ end
data/lib/cogworker/job.rb CHANGED
@@ -30,6 +30,32 @@ module Cogworker
30
30
  end
31
31
  end
32
32
 
33
+ # `cogworker_retry_in { |count, exception, job| ... }` — the pause
34
+ # (seconds) before the next attempt, or `:kill` (straight to dead) /
35
+ # `:discard` (dropped), or `nil` for the default backoff. See
36
+ # `FailureDecision`. Kept on the class, never in the job payload.
37
+ def cogworker_retry_in(&block)
38
+ raise ArgumentError, 'cogworker_retry_in needs a block' unless block
39
+
40
+ @cogworker_retry_in_block = block
41
+ end
42
+
43
+ # `cogworker_retries_exhausted { |job, exception| ... }` — called once
44
+ # the job is in dead for good (see `DeathNotifier`).
45
+ def cogworker_retries_exhausted(&block)
46
+ raise ArgumentError, 'cogworker_retries_exhausted needs a block' unless block
47
+
48
+ @cogworker_retries_exhausted_block = block
49
+ end
50
+
51
+ def cogworker_retry_in_block
52
+ inherited_hook(:@cogworker_retry_in_block, :cogworker_retry_in_block)
53
+ end
54
+
55
+ def cogworker_retries_exhausted_block
56
+ inherited_hook(:@cogworker_retries_exhausted_block, :cogworker_retries_exhausted_block)
57
+ end
58
+
33
59
  def perform_async(*args)
34
60
  Client.push(job_payload('args' => args))
35
61
  end
@@ -61,6 +87,14 @@ module Cogworker
61
87
 
62
88
  private
63
89
 
90
+ # Looked up at call time (not copied at definition), so a subclass
91
+ # sees a hook its parent defines later too.
92
+ def inherited_hook(ivar, reader)
93
+ return instance_variable_get(ivar) if instance_variable_defined?(ivar)
94
+
95
+ superclass.respond_to?(reader) ? superclass.public_send(reader) : nil
96
+ end
97
+
64
98
  def job_payload(extra)
65
99
  cogworker_options_hash.transform_keys(&:to_s).merge('class' => name).merge(extra)
66
100
  end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cogworker
4
+ # Handed to death hooks (DeathNotifier) for a failure filed in dead later,
5
+ # from its stored record, when the original exception object is long gone.
6
+ # The message is the original one; its class is in `job['error_class']`.
7
+ class JobFailedError < StandardError
8
+ end
9
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cogworker
4
+ # Handed to death hooks (DeathNotifier) for a job buried because its
5
+ # process kept dying while running it — there's no exception of its own.
6
+ class JobOrphanedError < StandardError
7
+ end
8
+ end
@@ -61,10 +61,46 @@ module Cogworker
61
61
  # `Periodic::ReleaseMiddleware` (releases the running-lock only on the
62
62
  # terminal attempt) — previously three separate, subtly different copies
63
63
  # of this same check, two of which had the `max_retries` bug above.
64
+ #
65
+ # A job class's `cogworker_retry_in` can end a job early (`:kill`,
66
+ # `:discard`): `Processor` stamps that decision on the job as
67
+ # `failure_outcome` (FailureDecision) before the middleware sees the
68
+ # failure, and it wins here when present.
64
69
  def terminal_failure?(job)
70
+ outcome = job['failure_outcome']
71
+ return outcome != 'retry' if FailureDecision::OUTCOMES.include?(outcome)
72
+
65
73
  job['retry_count'].to_i >= max_retries(job)
66
74
  end
67
75
 
76
+ # A copy of a job hash to hand to user code, so changes it makes can't
77
+ # leak back. Never raises: unlike a JSON round trip, it copes with
78
+ # whatever middleware may have put in the hash (the job being on its
79
+ # way to `interrupt` precisely because that doesn't serialize).
80
+ def deep_copy(value)
81
+ case value
82
+ when Hash then value.to_h { |k, v| [deep_copy(k), deep_copy(v)] }
83
+ when Array then value.map { |v| deep_copy(v) }
84
+ when String then value.dup
85
+ else value
86
+ end
87
+ end
88
+
89
+ # Calls a user-supplied hook (a block, lambda, Method or any `#call`
90
+ # object) with as many of `args` as it takes: a block with fewer
91
+ # parameters ignores the rest anyway, a lambda or method would raise.
92
+ # Counted from `parameters` (required + optional), not `arity`, which
93
+ # is negative as soon as there's an optional one.
94
+ def call_hook(hook, *args)
95
+ callable = hook.is_a?(Proc) || hook.is_a?(Method) ? hook : hook.method(:call)
96
+ return hook.call(*args) if callable.is_a?(Proc) && !callable.lambda?
97
+
98
+ params = callable.parameters
99
+ return hook.call(*args) if params.any? { |type, _| type == :rest }
100
+
101
+ hook.call(*args.first(params.count { |type, _| %i[req opt].include?(type) }))
102
+ end
103
+
68
104
  # The job Hash a raw `schedule`/`retry`/`dead` entry would requeue as —
69
105
  # or `nil` if it can't be: not JSON, not an object, or no String
70
106
  # `queue` to push it onto (e.g. an `unparseable_job` wrapper, whose
@@ -90,6 +90,8 @@ module Cogworker
90
90
  @busy = true
91
91
  @ran = nil
92
92
  @performed = false
93
+ @decision = nil
94
+ @death = nil
93
95
  disposition = nil
94
96
  begin
95
97
  disposition = execute(work)
@@ -104,6 +106,10 @@ module Cogworker
104
106
  # can't re-queue a job that already ran.
105
107
  settle(work, disposition || @ran || :give_back)
106
108
  @manager.job_finished(work.raw_job)
109
+ # Only now, with the job acknowledged (or its ack remembered): a
110
+ # slow hook used to hold it on the in-progress list, where a
111
+ # shutdown or orphan recovery requeued it and ran it again.
112
+ DeathNotifier.notify(*@death) if @death
107
113
  end
108
114
  end
109
115
 
@@ -139,9 +145,10 @@ module Cogworker
139
145
  payload.merge!('interrupted_count' => count, 'retry_count' => attempt,
140
146
  'error_class' => error.class.name, 'error_message' => JobUtil.error_message(error),
141
147
  'failed_at' => Time.now.to_f)
142
- # No retries left (`retry: false`/`0`, or the last one used up) means
143
- # no extra attempt either — straight to dead, like any terminal failure.
144
- out_of_retries = attempt > JobUtil.max_retries(payload)
148
+ # No retries left (`retry: false`/`0`, or the last one used up, or the
149
+ # job's `cogworker_retry_in` ended it) means no extra attempt either —
150
+ # straight to dead, like any terminal failure.
151
+ out_of_retries = attempt > JobUtil.max_retries(payload) || @decision&.cut_short?
145
152
  delay = INTERRUPT_DELAY * count
146
153
  set, score = if out_of_retries || count > MAX_INTERRUPTS
147
154
  [RedisKeys::DEAD, Time.now.to_f]
@@ -150,25 +157,15 @@ module Cogworker
150
157
  end
151
158
  encoded = JSON.generate(payload)
152
159
  begin
153
- fetcher.interrupt(work, set, score, encoded)
160
+ written = fetcher.interrupt(work, set, score, encoded) != 0 # (0: no longer ours to file)
154
161
  rescue StandardError
155
162
  # Remembered with its payload: this process's reconcile (and its
156
163
  # shutdown) keep trying to file it — never re-run it as a stray.
157
164
  @manager.settle_later(work.raw_job, [set, score, encoded]) if fetcher.is_a?(ReliableFetch)
158
165
  raise
159
166
  end
160
- if set == RedisKeys::RETRY
161
- best_effort('lock extension') { extend_locks_for_retry(job, delay) }
162
- else
163
- best_effort('lock release') { release_locks(payload) }
164
- end
165
- reason = if set == RedisKeys::RETRY
166
- "retrying in #{delay}s (interruption #{count} of at most #{MAX_INTERRUPTS})"
167
- elsif out_of_retries
168
- 'moved to dead: no retries left'
169
- else
170
- "moved to dead: interrupted more than #{MAX_INTERRUPTS} times"
171
- end
167
+ after_interrupt(job, payload, error, set, delay, written)
168
+ reason = interrupt_reason(set, out_of_retries, count, delay)
172
169
  Cogworker.logger.error { "couldn't record the failure of jid=#{payload['jid']} (#{error.class}); #{reason}" }
173
170
  rescue StandardError => e
174
171
  # Whatever failed (even before anything was written), remember the job
@@ -181,6 +178,21 @@ module Cogworker
181
178
  end
182
179
  end
183
180
 
181
+ def after_interrupt(job, payload, error, set, delay, written)
182
+ if set == RedisKeys::RETRY
183
+ best_effort('lock extension') { extend_locks_for_retry(job, delay) }
184
+ else
185
+ best_effort('lock release') { release_locks(payload) }
186
+ @death = [payload, error] if written # run by `process_one`, once settled
187
+ end
188
+ end
189
+
190
+ def interrupt_reason(set, out_of_retries, count, delay)
191
+ return "retrying in #{delay}s (interruption #{count} of at most #{MAX_INTERRUPTS})" if set == RedisKeys::RETRY
192
+
193
+ out_of_retries ? 'moved to dead: no retries left' : "moved to dead: interrupted more than #{MAX_INTERRUPTS} times"
194
+ end
195
+
184
196
  def remember_for_dead(work, error)
185
197
  # The original job's own fields where readable (so filing it releases
186
198
  # its locks); just enough to show in Dead otherwise.
@@ -272,10 +284,18 @@ module Cogworker
272
284
  nil
273
285
  end
274
286
 
287
+ @job_class = worker&.class
275
288
  Cogworker.config.server_chain.invoke(worker, job, work.queue) do
276
289
  raise resolution_error if resolution_error
277
290
 
278
- worker.perform(*job['args'])
291
+ begin
292
+ worker.perform(*job['args'])
293
+ rescue Exception => e # rubocop:disable Lint/RescueException
294
+ # Retry, dead or discard — decided here, while the middleware is
295
+ # still to see the failure (FailureDecision.decide!).
296
+ @decision = FailureDecision.decide!(job, e, @job_class)
297
+ raise
298
+ end
279
299
  end
280
300
  nil
281
301
  rescue Exception => e # rubocop:disable Lint/RescueException
@@ -349,22 +369,26 @@ module Cogworker
349
369
  # must happen; everything after it is best-effort. False if that write
350
370
  # failed (`finish_failure` then interrupts the job instead).
351
371
  def route_failure(job, error)
372
+ @decided_late = @decision.nil? # raised by middleware, not `perform`: decided after the chain
373
+ @decision ||= FailureDecision.decide!(job, error, @job_class)
374
+ decision = @decision || FailureDecision.new(JobUtil.terminal_failure?(job) ? 'dead' : 'retry')
375
+ job.delete('failure_outcome')
352
376
  job['error_class'] = error.class.name
353
377
  job['error_message'] = JobUtil.error_message(error)
354
378
  job['failed_at'] ||= Time.now.to_f
355
-
356
- max_retries = JobUtil.max_retries(job)
357
379
  new_count = job['retry_count'].to_i + 1
358
380
  job['retry_count'] = new_count
359
- retrying = new_count <= max_retries
360
- delay = retry_delay(new_count) if retrying
381
+ if decision.discard? # neither retry nor dead, no death hooks: the job just ends, counted as failed
382
+ after_failure_recorded(job, error, decision, nil)
383
+ return true
384
+ end
361
385
 
386
+ delay = decision.delay || retry_delay(new_count) if decision.retry?
362
387
  begin
363
388
  payload = JSON.generate(job)
364
389
  Cogworker.config.redis do |c|
365
- if retrying
366
- c.zadd(RedisKeys::RETRY, Time.now.to_f + delay,
367
- payload)
390
+ if decision.retry?
391
+ c.zadd(RedisKeys::RETRY, Time.now.to_f + delay, payload)
368
392
  else
369
393
  c.zadd(RedisKeys::DEAD, Time.now.to_f, payload)
370
394
  end
@@ -374,11 +398,25 @@ module Cogworker
374
398
  return false
375
399
  end
376
400
 
377
- best_effort('lock extension') { extend_locks_for_retry(job, delay) } if retrying
401
+ after_failure_recorded(job, error, decision, delay)
402
+ @death = [JSON.parse(payload), error] if decision.dead? # run by `process_one`, once settled
403
+ true
404
+ end
405
+
406
+ def after_failure_recorded(job, error, decision, delay)
407
+ if decision.retry?
408
+ best_effort('lock extension') { extend_locks_for_retry(job, delay) }
409
+ elsif decision.cut_short?
410
+ # Normally the release middleware's, on the terminal attempt — but
411
+ # a decision made after the chain (a middleware raised) never
412
+ # reached it. Owner-checked, so releasing twice is harmless. Same
413
+ # for the status the Status middleware wrote then: 'retrying'.
414
+ best_effort('lock release') { release_locks(job) }
415
+ best_effort('status') { Status.mark_failed(job, error) } if @decided_late
416
+ end
378
417
  best_effort('attempts log') do
379
- Attempts.record(job['jid'], attempt: new_count, error: error, outcome: retrying ? 'retrying' : 'dead')
418
+ Attempts.record(job['jid'], attempt: job['retry_count'], error: error, outcome: decision.attempt_outcome)
380
419
  end
381
- true
382
420
  end
383
421
 
384
422
  # A job waiting out its retry backoff still holds its `until_executed`
@@ -404,10 +442,9 @@ module Cogworker
404
442
  Cogworker.config.redis { |c| JobUtil.release_terminal_locks(c, job) }
405
443
  end
406
444
 
407
- # Grows with the retry count, with jitter to avoid a thundering herd of
408
- # retries all landing on the same second.
445
+ # The default backoff, for a job whose class has no say in it.
409
446
  def retry_delay(count)
410
- (count**4) + 15 + (rand(30) * (count + 1))
447
+ FailureDecision.default_delay(count)
411
448
  end
412
449
 
413
450
  def register_in_workers(queue, job)
@@ -404,20 +404,27 @@ module Cogworker
404
404
  # `config.max_orphanings` times) in `dead`, with an error saying why — whatever
405
405
  # its `retry` setting, since each of those runs ended with its process
406
406
  # gone rather than an error to retry on. Parked entries survive a
407
- # failure here and are picked up on the next pass.
407
+ # failure here and are picked up on the next pass. Death hooks run
408
+ # only for the entries this call itself moved (the script's 1), so
409
+ # processes reconciling side by side don't run them twice.
408
410
  def bury_repeat_orphans
411
+ buried_jobs = []
409
412
  Cogworker.config.redis do |c|
410
413
  c.lrange(RedisKeys::REPEAT_ORPHANS, 0, -1).each do |raw|
411
- job = parse_hash(raw) || JobUtil.unparseable_job(raw, queue: nil, error: JSON::ParserError.new('unreadable'))
414
+ readable = parse_hash(raw)
415
+ job = readable || JobUtil.unparseable_job(raw, queue: nil, error: JSON::ParserError.new('unreadable'))
412
416
  job.merge!(repeat_orphan_error)
413
417
  buried = LuaScript.run(c, BURY_ORPHAN_SCRIPT, keys: [RedisKeys::REPEAT_ORPHANS, RedisKeys::DEAD],
414
418
  argv: [raw, Time.now.to_f, JSON.generate(job)])
415
419
  next unless buried == 1
416
420
 
421
+ buried_jobs << job if readable # it's in dead now, whatever the bookkeeping below does
417
422
  Cogworker.logger.error { "jid=#{job['jid']} orphaned too often, moved to dead" }
418
423
  buried_as_dead(c, job)
419
424
  end
420
425
  end
426
+ ensure
427
+ buried_jobs.each { |job| DeathNotifier.notify_orphaned(job) }
421
428
  end
422
429
 
423
430
  def recover_identity(identity)
@@ -472,18 +479,20 @@ module Cogworker
472
479
  def settle_pending(identity, pending)
473
480
  key = RedisKeys.in_progress(identity)
474
481
  pending.each do |raw, how|
475
- Cogworker.config.redis do |c|
482
+ buried = Cogworker.config.redis do |c|
476
483
  if how == :ack
477
484
  jid = parse_hash(raw)&.fetch('jid', nil)
478
485
  LuaScript.run(c, ACK_SCRIPT, keys: [key, RedisKeys::ORPHANINGS_PREFIX + jid.to_s], argv: [raw])
486
+ nil
479
487
  else
480
488
  set, score, payload = how
481
489
  filed = LuaScript.run(c, INTERRUPT_SCRIPT, keys: [key, set, orphanings_key(raw)],
482
490
  argv: [raw, score, payload])
483
- released_if_dead(c, set, payload) if filed == 1
491
+ filed == 1 ? filed_in_dead(c, set, payload) : nil
484
492
  end
485
493
  end
486
494
  yield raw if block_given?
495
+ DeathNotifier.notify_failed(buried) if buried
487
496
  rescue StandardError => e
488
497
  Cogworker.logger.error do
489
498
  "still couldn't settle jid=#{parse_hash(raw)&.fetch('jid', nil)}: #{e.class}: #{e.message}"
@@ -516,7 +525,9 @@ module Cogworker
516
525
  # Files what shutting-down processes handed over in UNSETTLED, head
517
526
  # first, stopping at the first that still can't be filed (retried on
518
527
  # the next pass).
528
+ # Death hooks run for what this call filed in dead (the script's 1).
519
529
  def file_unsettled
530
+ buried = []
520
531
  Cogworker.config.redis do |c|
521
532
  c.lrange(RedisKeys::UNSETTLED, 0, -1).each do |record|
522
533
  entry = parse_hash(record)
@@ -525,9 +536,11 @@ module Cogworker
525
536
  filed = LuaScript.run(c, FILE_UNSETTLED_SCRIPT,
526
537
  keys: [RedisKeys::UNSETTLED, orphanings_key(entry['payload'])],
527
538
  argv: [record, entry['set'], entry['score'], entry['payload']])
528
- released_if_dead(c, entry['set'], entry['payload']) if filed == 1
539
+ buried << filed_in_dead(c, entry['set'], entry['payload']) if filed == 1
529
540
  end
530
541
  end
542
+ ensure
543
+ buried.compact.each { |payload| DeathNotifier.notify_failed(payload) }
531
544
  end
532
545
 
533
546
  # Jobs REQUEUE_SCRIPT had to file in dead raw (its parking list
@@ -538,13 +551,19 @@ module Cogworker
538
551
  # its error, its job's locks held and its failure uncounted.
539
552
  def complete_raw_burials(raws)
540
553
  raws.each do |raw|
541
- job = (parse_hash(raw) || {}).merge(repeat_orphan_error)
542
- Cogworker.config.redis do |c|
543
- swapped = LuaScript.run(c, SWAP_DEAD_ENTRY_SCRIPT, keys: [RedisKeys::DEAD],
544
- argv: [raw, Time.now.to_f, JSON.generate(job)])
545
- buried_as_dead(c, job) if swapped == 1
554
+ readable = parse_hash(raw)
555
+ job = (readable || {}).merge(repeat_orphan_error)
556
+ swapped = Cogworker.config.redis do |c|
557
+ LuaScript.run(c, SWAP_DEAD_ENTRY_SCRIPT, keys: [RedisKeys::DEAD],
558
+ argv: [raw, Time.now.to_f, JSON.generate(job)])
546
559
  end
547
560
  @raw_burials_mutex.synchronize { @pending_raw_burials.delete(raw) }
561
+ next unless swapped == 1
562
+
563
+ # The swap is done: the rest must not be retried (it would find
564
+ # nothing to swap and skip the hooks), so it's best-effort.
565
+ BestEffort.call('Orphan recovery') { Cogworker.config.redis { |c| buried_as_dead(c, job) } }
566
+ DeathNotifier.notify_orphaned(job) if readable
548
567
  rescue StandardError => e
549
568
  @raw_burials_mutex.synchronize { @pending_raw_burials << raw unless @pending_raw_burials.include?(raw) }
550
569
  RedisErrors.report('Orphan recovery', e) { "a repeat orphan's dead entry not completed (will retry)" }
@@ -565,11 +584,17 @@ module Cogworker
565
584
 
566
585
  # `set` must be one of ours to file into — not any key a damaged record
567
586
  # names (another type's, which would fail every pass at that record).
568
- # Filing a failure in dead outside the middleware chain: release its
569
- # locks, as the chain would for a terminal failure.
570
- def released_if_dead(conn, set, payload)
587
+ # Just filed by this process: if that was a (readable) job into dead,
588
+ # release its locks, as the chain would for a terminal failure, and
589
+ # return the payload — its death hooks are due. The release is
590
+ # best-effort: the job is in dead either way, and a raise here used
591
+ # to cost it its hooks (the next pass's script finds nothing to file).
592
+ def filed_in_dead(conn, set, payload)
571
593
  job = parse_hash(payload)
572
- JobUtil.release_terminal_locks(conn, job) if set == RedisKeys::DEAD && job
594
+ return nil unless set == RedisKeys::DEAD && job
595
+
596
+ BestEffort.call('Lock release') { JobUtil.release_terminal_locks(conn, job) }
597
+ payload
573
598
  end
574
599
 
575
600
  def valid_unsettled?(entry)
@@ -40,5 +40,17 @@ module Cogworker
40
40
  def get(jid)
41
41
  Storage.read(jid)
42
42
  end
43
+
44
+ # Corrects a status already written as 'retrying' to 'failed' — for a
45
+ # job whose `cogworker_retry_in` ended it (`:kill`/`:discard`) only
46
+ # after the middleware had seen the failure (a middleware raised, not
47
+ # `perform`). A no-op for a job nothing tracks the status of.
48
+ def mark_failed(job, error)
49
+ return unless Storage.read(job['jid'])
50
+
51
+ Storage.write(job['jid'], default_expiration || DEFAULT_EXPIRATION,
52
+ 'status' => 'failed', 'update_time' => Time.now.to_f,
53
+ 'error_class' => error.class.name, 'error_message' => JobUtil.error_message(error))
54
+ end
43
55
  end
44
56
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Cogworker
4
- VERSION = '0.6.0'
4
+ VERSION = '0.7.0'
5
5
  end
@@ -34,6 +34,12 @@ module Cogworker
34
34
  # (still capped at 25, but the same 25 either way) is one click
35
35
  # away via the History link below, not lost.
36
36
  ATTEMPTS_DISPLAY_LIMIT = 5
37
+ # How the retry timeline reads each `Attempts` outcome; `killed`/
38
+ # `discarded` are the job class's `cogworker_retry_in` ending it early.
39
+ ATTEMPT_OUTCOME_LABELS = {
40
+ 'dead' => 'moved to dead set', 'killed' => 'killed by retry_in, moved to dead set',
41
+ 'discarded' => 'discarded by retry_in'
42
+ }.freeze
37
43
  TAG_CLASS = { 'Enqueued' => 'tag-neutral', 'Running' => 'tag-accent', 'Scheduled' => 'tag-accent-2',
38
44
  'Retrying' => 'tag-warning', 'Dead' => 'tag-danger' }.freeze
39
45
 
@@ -378,14 +384,14 @@ module Cogworker
378
384
 
379
385
  shown = attempts.first(ATTEMPTS_DISPLAY_LIMIT)
380
386
  items = shown.map do |a|
381
- dot = a['outcome'] == 'dead' ? 'var(--color-danger)' : 'var(--color-warning)'
387
+ dot = a['outcome'] == 'retrying' ? 'var(--color-warning)' : 'var(--color-danger)'
382
388
  <<~HTML
383
389
  <div style="display: grid; grid-template-columns: 18px minmax(0, 1fr); gap: 10px;">
384
390
  <div style="display: flex; flex-direction: column; align-items: center;">
385
391
  <span style="width: 9px; height: 9px; border-radius: 50%; background: #{dot}; margin-top: 5px;"></span>
386
392
  </div>
387
393
  <div style="padding-bottom: 12px;">
388
- <div style="font-size: 13px;">Attempt #{a['attempt']} · #{a['outcome'] == 'dead' ? 'moved to dead set' : 'failed'}</div>
394
+ <div style="font-size: 13px;">Attempt #{a['attempt']} · #{ATTEMPT_OUTCOME_LABELS.fetch(a['outcome'], 'failed')}</div>
389
395
  <div style="font-size: 12px; color: var(--color-neutral-500);">#{Layout.h(Time.at(a['failed_at']).utc.strftime(Web.time_format))} · #{Layout.h(a['error_class'])}: #{Layout.h(a['error_message'])}</div>
390
396
  </div>
391
397
  </div>
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cogworker
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - vdonec
@@ -216,12 +216,16 @@ files:
216
216
  - lib/cogworker/component.rb
217
217
  - lib/cogworker/config.rb
218
218
  - lib/cogworker/config_loader.rb
219
+ - lib/cogworker/death_notifier.rb
219
220
  - lib/cogworker/deferred_releases.rb
221
+ - lib/cogworker/failure_decision.rb
220
222
  - lib/cogworker/heartbeat.rb
221
223
  - lib/cogworker/history.rb
222
224
  - lib/cogworker/history/middleware.rb
223
225
  - lib/cogworker/history/storage.rb
224
226
  - lib/cogworker/job.rb
227
+ - lib/cogworker/job_failed_error.rb
228
+ - lib/cogworker/job_orphaned_error.rb
225
229
  - lib/cogworker/job_record.rb
226
230
  - lib/cogworker/job_util.rb
227
231
  - lib/cogworker/key_guard.rb