cogworker 0.4.1 → 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
 
@@ -61,41 +65,113 @@ 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 { "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.
83
117
  def recover_orphans_if_due
84
118
  now = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
85
119
  return if @next_orphan_check && now < @next_orphan_check
86
120
 
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
121
  @next_orphan_check = now + ORPHAN_CHECK_INTERVAL
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)
92
152
  end
93
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.
94
164
  def enqueue_due_jobs
95
165
  now = Time.now.to_f
96
166
  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) }
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
99
175
  end
100
176
  end
101
177
 
@@ -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.0'
5
5
  end
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.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - vdonec
@@ -220,6 +220,7 @@ 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
225
226
  - lib/cogworker/lua_script.rb