cogworker 0.4.0 → 0.5.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.
@@ -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
 
@@ -55,37 +59,119 @@ module Cogworker
55
59
  @thread&.kill
56
60
  end
57
61
 
62
+ # True once the first orphan check has completed — what the periodic
63
+ # Ticker waits for before its first tick (see Launcher#initialize).
64
+ def recovered_once?
65
+ @recovered_once == true
66
+ end
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
+
58
78
  private
59
79
 
60
80
  # A failed poll is logged and retried next interval rather than ending
61
81
  # the thread — a single Redis blip used to stop every scheduled/retry
62
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.
63
86
  def run
64
87
  until @manager.stopping?
65
- begin
66
- recover_orphans_if_due
67
- enqueue_due_jobs unless @manager.quiet?
68
- rescue StandardError => e
69
- Cogworker.logger.error { "Scheduled poll failed: #{e.class}: #{e.message}" }
70
- 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? }
71
91
  sleep(POLL_INTERVAL)
72
92
  end
73
93
  end
74
94
 
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 { "Graduating an entry of #{set} failed (deferred #{DEFER_DELAY}s): #{error.class}: #{error.message}" }
101
+ Cogworker.config.redis { |c| c.zadd(set, Time.now.to_f + DEFER_DELAY, raw, xx: true) }
102
+ rescue StandardError => e
103
+ Cogworker.logger.error { "Deferring it failed too: #{e.class}: #{e.message}" }
104
+ end
105
+
106
+ def guarded(what)
107
+ yield
108
+ rescue StandardError => e
109
+ Cogworker.logger.error { "#{what} failed: #{e.class}: #{e.message}" }
110
+ end
111
+
112
+ # The next check is scheduled before this one runs: one that keeps
113
+ # failing (everything inside it is isolated, so that means Redis itself
114
+ # is failing) is retried every ORPHAN_CHECK_INTERVAL — or on the next
115
+ # poll if it lost the connection — never on every poll with a full SCAN.
116
+ # The one-off SCAN only counts as done once it has succeeded.
75
117
  def recover_orphans_if_due
76
118
  now = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
77
119
  return if @next_orphan_check && now < @next_orphan_check
78
120
 
79
- scan = @next_orphan_check.nil? && @manager.fetch_class == ReliableFetch
80
121
  @next_orphan_check = now + ORPHAN_CHECK_INTERVAL
81
- ReliableFetch.recover_orphans(scan: scan)
122
+ reliable = @manager.fetch_class == ReliableFetch
123
+ report = {}
124
+ # Lost the connection (anywhere in it): check again on the next poll,
125
+ # not a minute later — Redis coming back is when it matters most.
126
+ begin
127
+ ReliableFetch.recover_orphans(scan: reliable && !@scanned, report: report)
128
+ rescue Redis::BaseConnectionError
129
+ @next_orphan_check = now + POLL_INTERVAL
130
+ raise
131
+ end
132
+ @next_orphan_check = now + POLL_INTERVAL if report[:connection_lost]
133
+ guarded('Reconcile') { reconcile } if reliable
134
+ # Only once that actually looked: a pass that merely survived every
135
+ # error (recovery isolates them), or lost the connection part-way,
136
+ # mustn't let the Ticker start, nor count the one-off SCAN as done.
137
+ return if report[:connection_lost]
138
+
139
+ @scanned = true if report[:scan]
140
+ @recovered_once = true if report[:registry] || report[:scan]
141
+ end
142
+
143
+ # See KeyGuard. First thing on the first poll, then every
144
+ # KeyGuard::CHECK_INTERVAL — before recovery and polling, so a key that
145
+ # would break them is already out of the way.
146
+ def check_keys_if_due
147
+ now = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
148
+ return if @next_key_check && now < @next_key_check
149
+
150
+ @next_key_check = now + KeyGuard::CHECK_INTERVAL
151
+ KeyGuard.check(queues: @manager.queues)
82
152
  end
83
153
 
154
+ def reconcile
155
+ @strays = ReliableFetch.reconcile(Cogworker.identity, @strays || [],
156
+ running: @manager.running_jobs, pending: @manager.pending_settlements) do |raw|
157
+ @manager.settled(raw)
158
+ end
159
+ end
160
+
161
+ # Each set, and each entry, on its own: `schedule` unreadable mustn't
162
+ # stop `retry` from being polled, nor one entry whose graduation fails
163
+ # stop the rest of the batch.
84
164
  def enqueue_due_jobs
85
165
  now = Time.now.to_f
86
166
  SETS.each do |set|
87
- candidates = Cogworker.config.redis { |c| c.zrangebyscore(set, '-inf', now, limit: [0, 50]) }
88
- candidates.each { |raw| graduate(set, raw) }
167
+ guarded("Polling #{set}") do
168
+ candidates = Cogworker.config.redis { |c| c.zrangebyscore(set, '-inf', now, limit: [0, 50]) }
169
+ candidates.each do |raw|
170
+ graduate(set, raw)
171
+ rescue StandardError => e
172
+ defer(set, raw, e)
173
+ end
174
+ end
89
175
  end
90
176
  end
91
177
 
@@ -104,8 +190,8 @@ module Cogworker
104
190
  def bury(conn, set, raw)
105
191
  error = JSON::ParserError.new("not a job (JSON object with a queue) in #{set}")
106
192
  job = JobUtil.unparseable_job(raw, queue: nil, error: error)
107
- buried = conn.eval(BURY_SCRIPT, keys: [set, RedisKeys::DEAD, RedisKeys::STATS_FAILED],
108
- argv: [raw, job['failed_at'], JSON.generate(job)])
193
+ buried = LuaScript.run(conn, BURY_SCRIPT, keys: [set, RedisKeys::DEAD, RedisKeys::STATS_FAILED],
194
+ argv: [raw, job['failed_at'], JSON.generate(job)])
109
195
  return unless buried == 1
110
196
 
111
197
  Throughput.record('failed') # counted like Processor#bury_unparseable
@@ -15,7 +15,7 @@ module Cogworker
15
15
  write(job, queue, 'complete')
16
16
  rescue Exception => e # rubocop:disable Lint/RescueException
17
17
  write(job, queue, JobUtil.terminal_failure?(job) ? 'failed' : 'retrying',
18
- error_class: e.class.name, error_message: e.message.to_s[0, 10_000])
18
+ error_class: e.class.name, error_message: JobUtil.error_message(e))
19
19
  raise e
20
20
  end
21
21
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Cogworker
4
- VERSION = '0.4.0'
4
+ VERSION = '0.5.0'
5
5
  end
@@ -20,6 +20,13 @@ module Cogworker
20
20
  def call(env)
21
21
  env['rack.session'] ? @app.call(env) : @with_session.call(env)
22
22
  end
23
+
24
+ # A session middleware added via `Web.use` sits *after* this one in
25
+ # the stack, so it can't be detected per request the way an upstream
26
+ # one is — `Web.build_app` checks for one up front instead.
27
+ def self.session_middleware?(middleware)
28
+ middleware.is_a?(Class) && middleware <= Rack::Session::Abstract::Persisted
29
+ end
23
30
  end
24
31
  end
25
32
  end
data/lib/cogworker/web.rb CHANGED
@@ -104,9 +104,10 @@ module Cogworker
104
104
  @session_secret ||= ENV.fetch('COGWORKER_SESSION_SECRET') { SecureRandom.hex(32) }
105
105
  end
106
106
 
107
- # `false` drops the built-in session cookie entirely (it's already
108
- # skipped automatically whenever the host app provides a session —
109
- # see OptionalSession). Default true.
107
+ # `false` drops the built-in session cookie entirely. It's already
108
+ # skipped automatically whenever the host app provides a session
109
+ # (OptionalSession) or one is added via `Web.use` (a
110
+ # `Rack::Session::*` class — see `build_app`). Default true.
110
111
  def builtin_session
111
112
  @builtin_session.nil? || @builtin_session
112
113
  end
@@ -258,7 +259,7 @@ module Cogworker
258
259
 
259
260
  def build_app
260
261
  builder = Rack::Builder.new
261
- if builtin_session
262
+ if builtin_session && middlewares.none? { |mw, _| Cogworker::Web::OptionalSession.session_middleware?(mw) }
262
263
  builder.use(Cogworker::Web::OptionalSession, secret: session_secret, key: 'cogworker.session')
263
264
  end
264
265
  # No `cache_control:` (no `immutable`/long `max-age`): these files
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.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - vdonec
@@ -220,8 +220,10 @@ files:
220
220
  - lib/cogworker/job.rb
221
221
  - lib/cogworker/job_record.rb
222
222
  - lib/cogworker/job_util.rb
223
+ - lib/cogworker/key_guard.rb
223
224
  - lib/cogworker/launcher.rb
224
225
  - lib/cogworker/logging.rb
226
+ - lib/cogworker/lua_script.rb
225
227
  - lib/cogworker/manager.rb
226
228
  - lib/cogworker/middleware/chain.rb
227
229
  - lib/cogworker/owned_key.rb