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.
@@ -21,17 +21,21 @@ module Cogworker
21
21
  # is limited to processes actually using ReliableFetch. The requeue is
22
22
  # atomic, so any number of processes doing it at once is safe.
23
23
  ORPHAN_CHECK_INTERVAL = 60
24
+ DEFER_DELAY = 60
24
25
 
25
26
  # KEYS[1] = the ZSET, KEYS[2] = cogworker:dead, KEYS[3] = stats:failed;
26
27
  # ARGV[1] = the entry, ARGV[2] = its dead-set score, ARGV[3] = the
27
28
  # wrapper to store. Same claim-and-move-atomically shape as
28
29
  # JobUtil::CLAIM_AND_REQUEUE_SCRIPT.
30
+ # (Write, then remove — see JobUtil::CLAIM_AND_REQUEUE_SCRIPT; the stats
31
+ # counter is best-effort.)
29
32
  BURY_SCRIPT = <<~LUA
30
- if redis.call("ZREM", KEYS[1], ARGV[1]) == 0 then
33
+ if not redis.call("ZSCORE", KEYS[1], ARGV[1]) then
31
34
  return 0
32
35
  end
33
36
  redis.call("ZADD", KEYS[2], ARGV[2], ARGV[3])
34
- redis.call("INCR", KEYS[3])
37
+ redis.call("ZREM", KEYS[1], ARGV[1])
38
+ redis.pcall("INCR", KEYS[3])
35
39
  return 1
36
40
  LUA
37
41
 
@@ -61,41 +65,120 @@ module Cogworker
61
65
  @recovered_once == true
62
66
  end
63
67
 
68
+ # What the periodic Ticker waits for before its first tick: the first
69
+ # orphan check that really completed. No timeout: without Redis the
70
+ # Ticker couldn't do anything anyway, and a key that would keep the check
71
+ # failing for good is quarantined (KeyGuard) within a minute — whereas
72
+ # starting early after a long outage could run an `until_executed` slot
73
+ # alongside the recovered run of the previous one.
74
+ def ready_for_ticker?
75
+ recovered_once?
76
+ end
77
+
64
78
  private
65
79
 
66
80
  # A failed poll is logged and retried next interval rather than ending
67
81
  # the thread — a single Redis blip used to stop every scheduled/retry
68
82
  # job in this process from ever reaching its queue again.
83
+ # Recovery and graduating due jobs fail independently: a recovery that
84
+ # keeps failing used to skip `enqueue_due_jobs` on every poll, so no
85
+ # scheduled or retried job reached its queue anywhere in the fleet.
69
86
  def run
70
87
  until @manager.stopping?
71
- begin
72
- recover_orphans_if_due
73
- enqueue_due_jobs unless @manager.quiet?
74
- rescue StandardError => e
75
- Cogworker.logger.error { "Scheduled poll failed: #{e.class}: #{e.message}" }
76
- end
88
+ guarded('Key check') { check_keys_if_due }
89
+ guarded('Orphan check') { recover_orphans_if_due }
90
+ guarded('Scheduled poll') { enqueue_due_jobs unless @manager.quiet? }
77
91
  sleep(POLL_INTERVAL)
78
92
  end
79
93
  end
80
94
 
81
- # Next check scheduled only once this one has completed: a failed one
82
- # (Redis down) is retried on the next poll rather than a minute later.
95
+ # An entry that can't be graduated right now (its queue key of the wrong
96
+ # type, say) is pushed DEFER_DELAY into the future: left due, it would be
97
+ # one of the same oldest 50 picked again on every poll, and enough of
98
+ # them would starve everything behind them for good.
99
+ def defer(set, raw, error)
100
+ Cogworker.logger.error do
101
+ "Graduating an entry of #{set} failed (deferred #{DEFER_DELAY}s): #{error.class}: #{error.message}"
102
+ end
103
+ Cogworker.config.redis { |c| c.zadd(set, Time.now.to_f + DEFER_DELAY, raw, xx: true) }
104
+ rescue StandardError => e
105
+ Cogworker.logger.error { "Deferring it failed too: #{e.class}: #{e.message}" }
106
+ end
107
+
108
+ # A block returning :skipped did nothing (not due yet): that proves
109
+ # nothing about Redis, so it doesn't end an outage report either.
110
+ def guarded(what)
111
+ result = yield
112
+ RedisErrors.recovered(what) unless result == :skipped
113
+ result
114
+ rescue StandardError => e
115
+ RedisErrors.report(what, e)
116
+ end
117
+
118
+ # The next check is scheduled before this one runs: one that keeps
119
+ # failing (everything inside it is isolated, so that means Redis itself
120
+ # is failing) is retried every ORPHAN_CHECK_INTERVAL — or on the next
121
+ # poll if it lost the connection — never on every poll with a full SCAN.
122
+ # The one-off SCAN only counts as done once it has succeeded.
83
123
  def recover_orphans_if_due
84
124
  now = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
85
- return if @next_orphan_check && now < @next_orphan_check
125
+ return :skipped if @next_orphan_check && now < @next_orphan_check
86
126
 
87
- reliable = @manager.fetch_class == ReliableFetch
88
- ReliableFetch.recover_orphans(scan: !@recovered_once && reliable)
89
- @strays = ReliableFetch.reconcile(Cogworker.identity, @strays || []) if reliable
90
- @recovered_once = true
91
127
  @next_orphan_check = now + ORPHAN_CHECK_INTERVAL
128
+ reliable = @manager.fetch_class == ReliableFetch
129
+ report = {}
130
+ # Lost the connection (anywhere in it): check again on the next poll,
131
+ # not a minute later — Redis coming back is when it matters most.
132
+ begin
133
+ ReliableFetch.recover_orphans(scan: reliable && !@scanned, report: report)
134
+ rescue StandardError => e
135
+ @next_orphan_check = now + POLL_INTERVAL if RedisErrors.unavailable?(e)
136
+ raise
137
+ end
138
+ @next_orphan_check = now + POLL_INTERVAL if report[:connection_lost]
139
+ guarded('Reconcile') { reconcile } if reliable
140
+ # Only once that actually looked: a pass that merely survived every
141
+ # error (recovery isolates them), or lost the connection part-way,
142
+ # mustn't let the Ticker start, nor count the one-off SCAN as done.
143
+ return if report[:connection_lost]
144
+
145
+ @scanned = true if report[:scan]
146
+ @recovered_once = true if report[:registry] || report[:scan]
147
+ nil
148
+ end
149
+
150
+ # See KeyGuard. First thing on the first poll, then every
151
+ # KeyGuard::CHECK_INTERVAL — before recovery and polling, so a key that
152
+ # would break them is already out of the way.
153
+ def check_keys_if_due
154
+ now = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
155
+ return :skipped if @next_key_check && now < @next_key_check
156
+
157
+ @next_key_check = now + KeyGuard::CHECK_INTERVAL
158
+ KeyGuard.check(queues: @manager.queues)
92
159
  end
93
160
 
161
+ def reconcile
162
+ @strays = ReliableFetch.reconcile(Cogworker.identity, @strays || [],
163
+ running: @manager.running_jobs, pending: @manager.pending_settlements) do |raw|
164
+ @manager.settled(raw)
165
+ end
166
+ end
167
+
168
+ # Each set, and each entry, on its own: `schedule` unreadable mustn't
169
+ # stop `retry` from being polled, nor one entry whose graduation fails
170
+ # stop the rest of the batch.
94
171
  def enqueue_due_jobs
95
172
  now = Time.now.to_f
96
173
  SETS.each do |set|
97
- candidates = Cogworker.config.redis { |c| c.zrangebyscore(set, '-inf', now, limit: [0, 50]) }
98
- candidates.each { |raw| graduate(set, raw) }
174
+ guarded("Polling #{set}") do
175
+ candidates = Cogworker.config.redis { |c| c.zrangebyscore(set, '-inf', now, limit: [0, 50]) }
176
+ candidates.each do |raw|
177
+ graduate(set, raw)
178
+ rescue StandardError => e
179
+ defer(set, raw, e)
180
+ end
181
+ end
99
182
  end
100
183
  end
101
184
 
@@ -115,7 +198,7 @@ module Cogworker
115
198
  error = JSON::ParserError.new("not a job (JSON object with a queue) in #{set}")
116
199
  job = JobUtil.unparseable_job(raw, queue: nil, error: error)
117
200
  buried = LuaScript.run(conn, BURY_SCRIPT, keys: [set, RedisKeys::DEAD, RedisKeys::STATS_FAILED],
118
- argv: [raw, job['failed_at'], JSON.generate(job)])
201
+ argv: [raw, job['failed_at'], JSON.generate(job)])
119
202
  return unless buried == 1
120
203
 
121
204
  Throughput.record('failed') # counted like Processor#bury_unparseable
@@ -9,14 +9,20 @@ module Cogworker
9
9
  @expiration = expiration
10
10
  end
11
11
 
12
+ # Every status write is best-effort (BestEffort) — before `perform`
13
+ # as much as after: one failing used to count as the job failing.
12
14
  def call(_worker, job, queue)
13
- write(job, queue, 'working')
14
- yield
15
- write(job, queue, 'complete')
16
- rescue Exception => e # rubocop:disable Lint/RescueException
17
- write(job, queue, JobUtil.terminal_failure?(job) ? 'failed' : 'retrying',
18
- error_class: e.class.name, error_message: JobUtil.error_message(e))
19
- raise e
15
+ BestEffort.call('Status') { write(job, queue, 'working') }
16
+ begin
17
+ yield
18
+ rescue Exception => e # rubocop:disable Lint/RescueException
19
+ BestEffort.call('Status') do
20
+ write(job, queue, JobUtil.terminal_failure?(job) ? 'failed' : 'retrying',
21
+ error_class: e.class.name, error_message: JobUtil.error_message(e))
22
+ end
23
+ raise e
24
+ end
25
+ BestEffort.call('Status') { write(job, queue, 'complete') }
20
26
  end
21
27
 
22
28
  private
@@ -59,6 +59,8 @@ module Cogworker
59
59
  def fork_child(slot)
60
60
  pid = ::Process.fork do
61
61
  Cogworker.reset_identity!
62
+ RedisErrors.reset! # the parent's outage state isn't this child's
63
+ DeferredReleases.reset! # nor are its pending releases
62
64
  CLI.new.run(@argv.dup)
63
65
  # `exit!`, not plain `exit`: `Launcher#run` only returns once
64
66
  # `Manager#stop!` has had its bounded (default 25s) chance to join
@@ -12,12 +12,16 @@ module Cogworker
12
12
  # `unique: :until_executed`. Registered unconditionally in every
13
13
  # `Config`, same as `Periodic::ReleaseMiddleware`.
14
14
  class ReleaseMiddleware
15
+ # Releasing is best-effort (BestEffort): a failed release leaves the
16
+ # lock to its TTL, and only the job's own exception is re-raised.
15
17
  def call(_worker, job, _queue)
16
- yield
18
+ begin
19
+ yield
20
+ rescue Exception => e # rubocop:disable Lint/RescueException
21
+ release(job) if UniqueJobs.until_executed?(job) && JobUtil.terminal_failure?(job)
22
+ raise e
23
+ end
17
24
  release(job) if UniqueJobs.until_executed?(job)
18
- rescue Exception => e # rubocop:disable Lint/RescueException
19
- release(job) if UniqueJobs.until_executed?(job) && JobUtil.terminal_failure?(job)
20
- raise e
21
25
  end
22
26
 
23
27
  private
@@ -25,8 +29,15 @@ module Cogworker
25
29
  # Owner-checked: if this job's lock already expired and passed to a
26
30
  # newer copy, this must not release *that* one's lock (which would let
27
31
  # a third copy in while the newer one is still running).
32
+ # Owner-checked; retried later (DeferredReleases) if it fails, rather
33
+ # than leaving duplicates blocked for the lock's whole TTL.
28
34
  def release(job)
29
- OwnedKey.delete(RedisKeys.unique_lock(UniqueJobs.digest(job)), job['jid'])
35
+ key = RedisKeys.unique_lock(UniqueJobs.digest(job))
36
+ released = BestEffort.call('Unique lock') do
37
+ OwnedKey.delete(key, job['jid'])
38
+ true
39
+ end
40
+ DeferredReleases.add(key, job['jid']) unless released
30
41
  end
31
42
  end
32
43
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Cogworker
4
- VERSION = '0.4.1'
4
+ VERSION = '0.5.1'
5
5
  end
@@ -380,7 +380,9 @@ module Cogworker
380
380
  # Redis cards), where the default wrapper would just nest one bordered
381
381
  # box inside another.
382
382
  def table(headers, rows, empty_message: 'Nothing here.', wrapped: true)
383
- return %(<p class="text-muted" style="font-size: 13px; font-style: italic;">#{h(empty_message)}</p>) if rows.empty?
383
+ if rows.empty?
384
+ return %(<p class="text-muted" style="font-size: 13px; font-style: italic;">#{h(empty_message)}</p>)
385
+ end
384
386
 
385
387
  head = headers.map { |c| %(<th>#{h(c)}</th>) }.join
386
388
  body = rows.map do |row|
@@ -288,13 +288,13 @@ module Cogworker
288
288
  def retry_now_button(bucket, script_name, row, params)
289
289
  action = Layout.path(script_name, "jobs/#{bucket}/retry_now")
290
290
  form_with_extra(action, 'raw', row[:raw], 'retry now', params, variant: :primary, icon: 'arrow-clockwise',
291
- extra: {})
291
+ extra: {})
292
292
  end
293
293
 
294
294
  def retry_button(bucket, script_name, row, params)
295
295
  action = Layout.path(script_name, "jobs/#{bucket}/retry")
296
296
  form_with_extra(action, 'raw', row[:raw], 'retry', params, variant: :primary, icon: 'arrow-clockwise',
297
- extra: {})
297
+ extra: {})
298
298
  end
299
299
 
300
300
  # `Layout.form_button` only carries one hidden field. Every action
@@ -304,9 +304,9 @@ module Cogworker
304
304
  # since it isn't in the URL a plain form submits to) plus, for an
305
305
  # enqueued-job delete, `queue` (`Queue#delete` isn't keyed off the
306
306
  # raw payload alone the way the ZSETs are).
307
- def form_with_extra(action, hidden_name, hidden_value, label, params, variant:, icon: nil, extra:)
307
+ def form_with_extra(action, hidden_name, hidden_value, label, params, variant:, extra:, icon: nil)
308
308
  hidden = extra.merge('status' => params['status'].to_s, 'q' => params['q'].to_s,
309
- 'selected' => params['selected'].to_s)
309
+ 'selected' => params['selected'].to_s)
310
310
  hidden_inputs = hidden.map { |k, v| %(<input type="hidden" name="#{k}" value="#{Layout.h(v)}">) }.join
311
311
  classes = "btn #{Layout::BUTTON_VARIANTS.fetch(variant)}"
312
312
  <<~HTML
@@ -613,7 +613,9 @@ module Cogworker
613
613
  def render_layout_b(script_name, selected_name)
614
614
  names = queue_names
615
615
  selected_name = names.first if (selected_name.nil? || selected_name.empty?) && !names.empty?
616
- return %(<p class="text-muted">No queues yet — push a job to create one.</p>) if selected_name.nil? || selected_name.empty?
616
+ if selected_name.nil? || selected_name.empty?
617
+ return %(<p class="text-muted">No queues yet — push a job to create one.</p>)
618
+ end
617
619
 
618
620
  <<~HTML
619
621
  <div style="display: grid; grid-template-columns: minmax(240px, 320px) minmax(0, 1fr); gap: 16px; align-items: start;">
@@ -676,7 +678,7 @@ module Cogworker
676
678
  end.join
677
679
  paused_tag = q.paused? ? " #{Layout.badge('paused', variant: :warning)}" : ''
678
680
  header_actions = queue_pause_button(q, script_name, 'b') + retry_all_button(name, script_name,
679
- retrying_count)
681
+ retrying_count)
680
682
  <<~HTML
681
683
  <section style="display: flex; flex-direction: column; gap: 16px; min-width: 0;">
682
684
  <div style="background: var(--color-surface); border-radius: var(--radius-md); box-shadow: var(--shadow-sm); padding: 18px;">
@@ -3,6 +3,9 @@
3
3
  require 'json'
4
4
 
5
5
  module Cogworker
6
+ # Every job running right now, across all live processes: yields
7
+ # `[identity, thread id, Cogworker::Work]` from each process's
8
+ # `cogworker:workers:<identity>` Hash (only for processes ProcessSet knows).
6
9
  class WorkSet
7
10
  include Enumerable
8
11
 
data/lib/cogworker.rb CHANGED
@@ -101,7 +101,7 @@ module Cogworker
101
101
  # No-op: exists so calling code doesn't need to change, but this gem
102
102
  # simply never coerces job args, so there is nothing to toggle — see
103
103
  # README "Job arguments".
104
- def strict_args!(_value = true)
104
+ def strict_args!(_value = true) # rubocop:disable Style/OptionalBooleanParameter -- kept positional for calling-code compatibility
105
105
  true
106
106
  end
107
107
 
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.4.1
4
+ version: 0.5.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - vdonec
@@ -203,16 +203,20 @@ executables:
203
203
  extensions: []
204
204
  extra_rdoc_files: []
205
205
  files:
206
+ - LICENSE.txt
207
+ - README.md
206
208
  - exe/cogworker
207
209
  - exe/cogworkerswarm
208
210
  - lib/cogworker.rb
209
211
  - lib/cogworker/attempts.rb
210
212
  - lib/cogworker/basic_fetch.rb
213
+ - lib/cogworker/best_effort.rb
211
214
  - lib/cogworker/cli.rb
212
215
  - lib/cogworker/client.rb
213
216
  - lib/cogworker/component.rb
214
217
  - lib/cogworker/config.rb
215
218
  - lib/cogworker/config_loader.rb
219
+ - lib/cogworker/deferred_releases.rb
216
220
  - lib/cogworker/heartbeat.rb
217
221
  - lib/cogworker/history.rb
218
222
  - lib/cogworker/history/middleware.rb
@@ -220,6 +224,7 @@ files:
220
224
  - lib/cogworker/job.rb
221
225
  - lib/cogworker/job_record.rb
222
226
  - lib/cogworker/job_util.rb
227
+ - lib/cogworker/key_guard.rb
223
228
  - lib/cogworker/launcher.rb
224
229
  - lib/cogworker/logging.rb
225
230
  - lib/cogworker/lua_script.rb
@@ -238,6 +243,7 @@ files:
238
243
  - lib/cogworker/prometheus/exporter.rb
239
244
  - lib/cogworker/queue.rb
240
245
  - lib/cogworker/redis_connection.rb
246
+ - lib/cogworker/redis_errors.rb
241
247
  - lib/cogworker/redis_keys.rb
242
248
  - lib/cogworker/reliable_fetch.rb
243
249
  - lib/cogworker/reliable_fetch/unsupported_error.rb