cogworker 0.5.0 → 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.
- checksums.yaml +4 -4
- data/LICENSE.txt +21 -0
- data/README.md +596 -0
- data/lib/cogworker/basic_fetch.rb +2 -2
- data/lib/cogworker/best_effort.rb +23 -0
- data/lib/cogworker/cli.rb +3 -2
- data/lib/cogworker/client.rb +17 -4
- data/lib/cogworker/config.rb +4 -1
- data/lib/cogworker/deferred_releases.rb +46 -0
- data/lib/cogworker/heartbeat.rb +56 -12
- data/lib/cogworker/history/middleware.rb +9 -5
- data/lib/cogworker/job_util.rb +17 -0
- data/lib/cogworker/key_guard.rb +36 -1
- data/lib/cogworker/launcher.rb +3 -0
- data/lib/cogworker/lua_script.rb +3 -3
- data/lib/cogworker/manager.rb +19 -1
- data/lib/cogworker/periodic/claim.lua +5 -2
- data/lib/cogworker/periodic/release_middleware.rb +13 -2
- data/lib/cogworker/periodic/running_lock.rb +12 -0
- data/lib/cogworker/periodic/ticker.rb +44 -15
- data/lib/cogworker/process_set.rb +3 -0
- data/lib/cogworker/processor.rb +33 -9
- data/lib/cogworker/redis_errors.rb +93 -0
- data/lib/cogworker/redis_keys.rb +4 -0
- data/lib/cogworker/reliable_fetch.rb +155 -40
- data/lib/cogworker/scheduled.rb +15 -8
- data/lib/cogworker/status/server_middleware.rb +13 -7
- data/lib/cogworker/swarm.rb +2 -0
- data/lib/cogworker/unique_jobs/release_middleware.rb +16 -5
- data/lib/cogworker/version.rb +1 -1
- data/lib/cogworker/web/layout.rb +3 -1
- data/lib/cogworker/web/routes/jobs.rb +4 -4
- data/lib/cogworker/web/routes/overview.rb +4 -2
- data/lib/cogworker/work_set.rb +3 -0
- data/lib/cogworker.rb +1 -1
- metadata +6 -1
data/lib/cogworker/client.rb
CHANGED
|
@@ -43,16 +43,29 @@ module Cogworker
|
|
|
43
43
|
end
|
|
44
44
|
end
|
|
45
45
|
|
|
46
|
+
# The queue listing only feeds the Web UI/stats; failing to update it
|
|
47
|
+
# is logged, never raised — whatever the error, Redis being away
|
|
48
|
+
# included: the job is already queued by then, and raising would make
|
|
49
|
+
# the caller push it a second time (see raw_push).
|
|
50
|
+
def register_queue(conn, queue)
|
|
51
|
+
conn.sadd?(RedisKeys::QUEUES, queue)
|
|
52
|
+
rescue StandardError => e
|
|
53
|
+
RedisErrors.report("#{RedisKeys::QUEUES} not updated", e) { queue }
|
|
54
|
+
end
|
|
55
|
+
|
|
46
56
|
def raw_push(job)
|
|
47
57
|
Cogworker.config.redis do |conn|
|
|
48
58
|
if job['at']
|
|
49
59
|
conn.zadd(RedisKeys::SCHEDULE, job['at'].to_f, JSON.generate(job))
|
|
50
60
|
else
|
|
51
61
|
job['enqueued_at'] = Time.now.to_f
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
62
|
+
# The push is the one write that matters, and goes first, on its
|
|
63
|
+
# own. They used to share a MULTI — which doesn't roll back: with
|
|
64
|
+
# `cogworker:queues` unwritable the job was queued anyway but the
|
|
65
|
+
# whole push raised, so callers retried it (the cron ticker did,
|
|
66
|
+
# every tick) and queued it again and again.
|
|
67
|
+
conn.lpush(RedisKeys.queue(job['queue']), JSON.generate(job))
|
|
68
|
+
register_queue(conn, job['queue'])
|
|
56
69
|
end
|
|
57
70
|
end
|
|
58
71
|
end
|
data/lib/cogworker/config.rb
CHANGED
|
@@ -61,7 +61,10 @@ module Cogworker
|
|
|
61
61
|
end
|
|
62
62
|
|
|
63
63
|
def max_orphanings=(count)
|
|
64
|
-
|
|
64
|
+
unless count.is_a?(Integer) && count.positive?
|
|
65
|
+
raise ArgumentError,
|
|
66
|
+
"max_orphanings must be a positive Integer, got #{count.inspect}"
|
|
67
|
+
end
|
|
65
68
|
|
|
66
69
|
@max_orphanings = count
|
|
67
70
|
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cogworker
|
|
4
|
+
# Lock releases that failed (Redis away when a job finished) — kept here
|
|
5
|
+
# and retried on every heartbeat beat (Heartbeat#beat) until they go
|
|
6
|
+
# through, owner-checked like the original release. A failed release is
|
|
7
|
+
# best-effort so it can't fail the job, but left alone it held the lock
|
|
8
|
+
# until its TTL: an `until_executed` cron entry skipped slots for up to
|
|
9
|
+
# `RunningLock.active_ttl`, a unique job blocked its duplicates for up to
|
|
10
|
+
# `unique_lock_ttl`. In this process's memory only: one still pending when
|
|
11
|
+
# the process exits falls back to the lock's TTL.
|
|
12
|
+
module DeferredReleases
|
|
13
|
+
@mutex = Mutex.new
|
|
14
|
+
@pending = {} # lock key => owner jid
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
def add(key, owner)
|
|
18
|
+
@mutex.synchronize { @pending[key] = owner }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Retries every pending release; stops at the first one Redis can't
|
|
22
|
+
# serve (no point hammering it), keeps whatever's left.
|
|
23
|
+
def retry_all(conn)
|
|
24
|
+
pending.each do |key, owner|
|
|
25
|
+
OwnedKey.delete(key, owner, conn) # (a no-op if it lapsed or changed hands meanwhile)
|
|
26
|
+
@mutex.synchronize { @pending.delete(key) if @pending[key] == owner }
|
|
27
|
+
rescue StandardError => e
|
|
28
|
+
raise if RedisErrors.unavailable?(e)
|
|
29
|
+
|
|
30
|
+
# Anything else (the key holding the wrong type, say) won't get
|
|
31
|
+
# better by retrying every 5s: dropped, logged once.
|
|
32
|
+
@mutex.synchronize { @pending.delete(key) if @pending[key] == owner }
|
|
33
|
+
Cogworker.logger.error { "deferred release of #{key} dropped: #{e.class}: #{e.message}" }
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def pending
|
|
38
|
+
@mutex.synchronize { @pending.dup }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def reset!
|
|
42
|
+
@mutex.synchronize { @pending.clear }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
data/lib/cogworker/heartbeat.rb
CHANGED
|
@@ -99,9 +99,12 @@ module Cogworker
|
|
|
99
99
|
|
|
100
100
|
def beat_safely
|
|
101
101
|
beat
|
|
102
|
+
@beat_failed = false
|
|
103
|
+
RedisErrors.recovered('Heartbeat')
|
|
102
104
|
true
|
|
103
105
|
rescue StandardError => e
|
|
104
|
-
|
|
106
|
+
@beat_failed = true
|
|
107
|
+
RedisErrors.report('Heartbeat', e)
|
|
105
108
|
false
|
|
106
109
|
end
|
|
107
110
|
|
|
@@ -128,21 +131,43 @@ module Cogworker
|
|
|
128
131
|
'busy', @manager.busy_count.to_s,
|
|
129
132
|
'quiet', @manager.quiet?.to_s)
|
|
130
133
|
c.expire(RedisKeys.process(identity), TTL)
|
|
131
|
-
|
|
132
|
-
secondary('workers expiry') { c.expire(RedisKeys.workers(identity), TTL) }
|
|
133
|
-
if reliable
|
|
134
|
-
secondary('in-progress registry') { c.sadd?(RedisKeys::IN_PROGRESS_IDENTITIES, identity) }
|
|
135
|
-
secondary('last-beat stamp') { c.hset(RedisKeys::LAST_BEAT, identity, redis_now(c)) }
|
|
136
|
-
end
|
|
134
|
+
write_registries(c, identity, reliable)
|
|
137
135
|
touch_running_periodic_locks(c, identity)
|
|
136
|
+
# Not via #secondary: Redis failing here, after the presence key went
|
|
137
|
+
# through, doesn't make this beat a failed one.
|
|
138
|
+
BestEffort.call('Deferred lock releases') { DeferredReleases.retry_all(c) }
|
|
138
139
|
end
|
|
139
140
|
end
|
|
140
141
|
|
|
142
|
+
# Everything a beat writes besides the presence key — each on its own,
|
|
143
|
+
# best-effort (see #secondary).
|
|
144
|
+
def write_registries(conn, identity, reliable)
|
|
145
|
+
secondary('process registry') { conn.sadd?(RedisKeys::PROCESSES, identity) }
|
|
146
|
+
# The queues this process reads are listed even while empty: the Web
|
|
147
|
+
# UI shows them, and recovery can tell a queue nobody reads from one
|
|
148
|
+
# that's merely had nothing pushed yet.
|
|
149
|
+
secondary('queue registry') { conn.sadd(RedisKeys::QUEUES, @manager.queues.uniq) }
|
|
150
|
+
secondary('live queue registry') { mark_queues_live(conn) }
|
|
151
|
+
secondary('workers expiry') { conn.expire(RedisKeys.workers(identity), TTL) }
|
|
152
|
+
return unless reliable
|
|
153
|
+
|
|
154
|
+
secondary('in-progress registry') { conn.sadd?(RedisKeys::IN_PROGRESS_IDENTITIES, identity) }
|
|
155
|
+
secondary('last-beat stamp') { conn.hset(RedisKeys::LAST_BEAT, identity, redis_now(conn)) }
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Stamps this process's queues as read right now, and drops ones no
|
|
159
|
+
# process has stamped for a day (only ever a listing, never data).
|
|
160
|
+
def mark_queues_live(conn)
|
|
161
|
+
now = redis_now(conn)
|
|
162
|
+
conn.zadd(RedisKeys::LIVE_QUEUES, @manager.queues.uniq.map { |q| [now, q] })
|
|
163
|
+
conn.zremrangebyscore(RedisKeys::LIVE_QUEUES, '-inf', now - 86_400)
|
|
164
|
+
end
|
|
165
|
+
|
|
141
166
|
def secondary(what)
|
|
142
167
|
yield
|
|
143
|
-
rescue Redis::BaseConnectionError
|
|
144
|
-
raise # Redis itself is gone: the beat as a whole failed
|
|
145
168
|
rescue StandardError => e
|
|
169
|
+
raise if RedisErrors.unavailable?(e) # Redis itself is away: the beat as a whole failed
|
|
170
|
+
|
|
146
171
|
Cogworker.logger.error { "Heartbeat: #{what} not written: #{e.class}: #{e.message}" }
|
|
147
172
|
end
|
|
148
173
|
|
|
@@ -161,12 +186,30 @@ module Cogworker
|
|
|
161
186
|
# see Periodic::RunningLock. From the Manager's own in-memory record of
|
|
162
187
|
# what's running here, not `cogworker:workers` in Redis: a failover that
|
|
163
188
|
# lost that Hash would otherwise let a long run's lock lapse mid-run.
|
|
189
|
+
# (One narrow race remains: a release landing between this beat's
|
|
190
|
+
# snapshot of `performing_jobs` and its Lua call, on the first beat
|
|
191
|
+
# after a failed one, can be undone — the lock then lapses on
|
|
192
|
+
# `active_ttl`. Not worth a cross-thread lock on every beat.)
|
|
193
|
+
# Only jobs still inside `perform` (`performing_jobs`): once it returns,
|
|
194
|
+
# Periodic::ReleaseMiddleware has released the lock, and refreshing it
|
|
195
|
+
# again would bring it back. A missing lock is only *re-taken* on the
|
|
196
|
+
# first beat after a failed one — i.e. after Redis was away, possibly
|
|
197
|
+
# long enough for the lock to lapse; otherwise a missing lock means it
|
|
198
|
+
# was released, and stays released.
|
|
164
199
|
def touch_running_periodic_locks(conn, _identity)
|
|
165
|
-
|
|
200
|
+
recovering = @beat_failed
|
|
201
|
+
@manager.performing_jobs.each do |raw|
|
|
166
202
|
job = JSON.parse(raw)
|
|
167
203
|
next unless job.is_a?(Hash) && job['periodic_pjid']
|
|
168
204
|
|
|
169
|
-
|
|
205
|
+
if recovering && job['periodic_until_executed']
|
|
206
|
+
# Re-taken, not just extended: after Redis was away longer than
|
|
207
|
+
# `active_ttl`, the lock is gone while the run is still going, and
|
|
208
|
+
# the ticker could start the next slot alongside it.
|
|
209
|
+
Periodic::RunningLock.retake(job['periodic_pjid'], job['jid'], Periodic::RunningLock.active_ttl, conn)
|
|
210
|
+
else
|
|
211
|
+
Periodic::RunningLock.touch(job['periodic_pjid'], job['jid'], Periodic::RunningLock.active_ttl, conn)
|
|
212
|
+
end
|
|
170
213
|
rescue StandardError => e
|
|
171
214
|
# Per entry: one bad entry mustn't leave the others' locks to expire.
|
|
172
215
|
Cogworker.logger.error { "couldn't refresh periodic lock for #{raw.to_s[0, 200]}: #{e.class}: #{e.message}" }
|
|
@@ -199,6 +242,7 @@ module Cogworker
|
|
|
199
242
|
def subscribe_loop
|
|
200
243
|
@subscribe_client = ::Redis.new(RedisConnection.client_options(Cogworker.config.redis_options))
|
|
201
244
|
@subscribe_client.subscribe(RedisKeys.signal(Cogworker.identity)) do |on|
|
|
245
|
+
on.subscribe { |*| RedisErrors.recovered('Signal subscriber') }
|
|
202
246
|
on.message do |_channel, message|
|
|
203
247
|
dispatch(message)
|
|
204
248
|
end
|
|
@@ -206,7 +250,7 @@ module Cogworker
|
|
|
206
250
|
rescue StandardError => e
|
|
207
251
|
return if @stopping
|
|
208
252
|
|
|
209
|
-
|
|
253
|
+
RedisErrors.report('Signal subscriber', e)
|
|
210
254
|
sleep(1)
|
|
211
255
|
retry
|
|
212
256
|
end
|
|
@@ -5,13 +5,17 @@ module Cogworker
|
|
|
5
5
|
# Server middleware: times every run and records it via Storage,
|
|
6
6
|
# success or failure, regardless of what other middleware/worker code does.
|
|
7
7
|
class Middleware
|
|
8
|
+
# Recording is best-effort (BestEffort): only the job's own exception
|
|
9
|
+
# is re-raised, never one from writing its history.
|
|
8
10
|
def call(_worker, job, queue)
|
|
9
11
|
started_at = Time.now.to_f
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
begin
|
|
13
|
+
yield
|
|
14
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
15
|
+
BestEffort.call('History') { Storage.record(job, queue, started_at, Time.now.to_f, 'failed', error: e) }
|
|
16
|
+
raise e
|
|
17
|
+
end
|
|
18
|
+
BestEffort.call('History') { Storage.record(job, queue, started_at, Time.now.to_f, 'success') }
|
|
15
19
|
end
|
|
16
20
|
end
|
|
17
21
|
end
|
data/lib/cogworker/job_util.rb
CHANGED
|
@@ -142,6 +142,23 @@ module Cogworker
|
|
|
142
142
|
"#<#{error.class}>"
|
|
143
143
|
end
|
|
144
144
|
|
|
145
|
+
# A job on its way to dead outside the usual path (no server middleware
|
|
146
|
+
# around it to do this): releases its `until_executed` locks, owner-
|
|
147
|
+
# checked, each on its own — so the entry / the unique key isn't blocked
|
|
148
|
+
# until the locks' TTL. Best-effort.
|
|
149
|
+
def release_terminal_locks(conn, job)
|
|
150
|
+
keys = []
|
|
151
|
+
keys << RedisKeys.unique_lock(UniqueJobs.digest(job)) if UniqueJobs.until_executed?(job)
|
|
152
|
+
keys << RedisKeys.periodic_running(job['periodic_pjid']) if job['periodic_pjid']
|
|
153
|
+
keys.each do |key|
|
|
154
|
+
released = BestEffort.call('Lock release') do
|
|
155
|
+
OwnedKey.delete(key, job['jid'], conn)
|
|
156
|
+
true
|
|
157
|
+
end
|
|
158
|
+
DeferredReleases.add(key, job['jid']) unless released
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
145
162
|
UNPARSEABLE_CLASS = '(unparseable)'
|
|
146
163
|
|
|
147
164
|
# A payload that isn't a JSON object can't be run, retried or even
|
data/lib/cogworker/key_guard.rb
CHANGED
|
@@ -22,6 +22,7 @@ module Cogworker
|
|
|
22
22
|
RedisKeys::QUEUES => 'set', RedisKeys::PROCESSES => 'set', RedisKeys::PAUSED_QUEUES => 'set',
|
|
23
23
|
RedisKeys::IN_PROGRESS_IDENTITIES => 'set', RedisKeys::LAST_BEAT => 'hash',
|
|
24
24
|
RedisKeys::REPEAT_ORPHANS => 'list', RedisKeys::UNSETTLED => 'list', RedisKeys::PERIODIC_SCHEDULE => 'hash',
|
|
25
|
+
RedisKeys::LIVE_QUEUES => 'zset',
|
|
25
26
|
RedisKeys::PERIODIC_DISABLED => 'set', RedisKeys::STATS_PROCESSED => 'string',
|
|
26
27
|
RedisKeys::STATS_FAILED => 'string'
|
|
27
28
|
}.freeze
|
|
@@ -65,9 +66,41 @@ module Cogworker
|
|
|
65
66
|
types[RedisKeys.process(id)] = 'hash'
|
|
66
67
|
types[RedisKeys.workers(id)] = 'hash'
|
|
67
68
|
end
|
|
69
|
+
readable_fields(RedisKeys::PERIODIC_SCHEDULE).each do |pjid|
|
|
70
|
+
types[RedisKeys.periodic_running(pjid)] = 'string'
|
|
71
|
+
types[RedisKeys.periodic_last_slot(pjid)] = 'string'
|
|
72
|
+
end
|
|
68
73
|
types
|
|
69
74
|
end
|
|
70
75
|
|
|
76
|
+
def readable_fields(hash)
|
|
77
|
+
Cogworker.config.redis { |c| c.hkeys(hash) }
|
|
78
|
+
rescue Redis::CommandError
|
|
79
|
+
[]
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
MAX_COPIES = 5
|
|
83
|
+
|
|
84
|
+
# Keeps the newest MAX_COPIES quarantined copies of one key: something
|
|
85
|
+
# that keeps writing the wrong type would otherwise leave one more every
|
|
86
|
+
# CHECK_INTERVAL, for QUARANTINE_TTL. (A SCAN, but only on the rare
|
|
87
|
+
# occasion a key actually gets quarantined.)
|
|
88
|
+
def prune_copies(key)
|
|
89
|
+
prefix = "#{QUARANTINE_PREFIX}#{key}:"
|
|
90
|
+
exact = /\A#{Regexp.escape(prefix)}(\d+):\h{8}\z/ # this key's copies only — not `<key>:<more>:...`'s
|
|
91
|
+
Cogworker.config.redis do |c|
|
|
92
|
+
copies = c.scan_each(match: "#{glob_escape(prefix)}*").select { |name| name.match?(exact) }
|
|
93
|
+
stale = copies.sort_by { |name| name[exact, 1].to_i }.reverse.drop(MAX_COPIES)
|
|
94
|
+
c.del(*stale) unless stale.empty?
|
|
95
|
+
end
|
|
96
|
+
rescue StandardError => e
|
|
97
|
+
Cogworker.logger.error { "pruning quarantined copies of #{key} failed: #{e.class}: #{e.message}" }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def glob_escape(text)
|
|
101
|
+
text.gsub(/[*?\[\]\\]/) { |ch| "\\#{ch}" }
|
|
102
|
+
end
|
|
103
|
+
|
|
71
104
|
def readable_members(set)
|
|
72
105
|
Cogworker.config.redis { |c| c.smembers(set) }
|
|
73
106
|
rescue Redis::CommandError
|
|
@@ -77,12 +110,14 @@ module Cogworker
|
|
|
77
110
|
def quarantine(key, expected, actual)
|
|
78
111
|
# Unique per move: a second quarantine of the same key within the same
|
|
79
112
|
# second must not overwrite (RENAME) the first one's copy.
|
|
80
|
-
|
|
113
|
+
# Milliseconds, so copies made within one second still sort by age.
|
|
114
|
+
target = "#{QUARANTINE_PREFIX}#{key}:#{(Time.now.to_f * 1000).to_i}:#{SecureRandom.hex(4)}"
|
|
81
115
|
moved = Cogworker.config.redis do |c|
|
|
82
116
|
LuaScript.run(c, QUARANTINE_SCRIPT, keys: [key, target], argv: [expected, QUARANTINE_TTL])
|
|
83
117
|
end
|
|
84
118
|
return false unless moved == 1
|
|
85
119
|
|
|
120
|
+
prune_copies(key)
|
|
86
121
|
Cogworker.logger.error do
|
|
87
122
|
"#{key} held a #{actual}, not a #{expected}: moved to #{target} (kept #{QUARANTINE_TTL / 86_400} days)"
|
|
88
123
|
end
|
data/lib/cogworker/launcher.rb
CHANGED
|
@@ -45,6 +45,9 @@ module Cogworker
|
|
|
45
45
|
|
|
46
46
|
def stop!
|
|
47
47
|
@manager.stop!
|
|
48
|
+
# Last chance for releases deferred by a recent outage — they live in
|
|
49
|
+
# this process's memory and would otherwise wait out their locks' TTL.
|
|
50
|
+
BestEffort.call('Deferred lock releases') { @config.redis { |c| DeferredReleases.retry_all(c) } }
|
|
48
51
|
@scheduled.stop!
|
|
49
52
|
@ticker.stop!
|
|
50
53
|
@heartbeat.stop!
|
data/lib/cogworker/lua_script.rb
CHANGED
|
@@ -9,8 +9,8 @@ module Cogworker
|
|
|
9
9
|
# restarted or failed-over server, SCRIPT FLUSH). Every script in this
|
|
10
10
|
# gem goes through here.
|
|
11
11
|
module LuaScript
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
@shas = {} # source => sha1; memoized, the sources are frozen constants
|
|
13
|
+
@shas_mutex = Mutex.new
|
|
14
14
|
|
|
15
15
|
module_function
|
|
16
16
|
|
|
@@ -24,7 +24,7 @@ module Cogworker
|
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
def sha(source)
|
|
27
|
-
|
|
27
|
+
@shas_mutex.synchronize { @shas[source] ||= Digest::SHA1.hexdigest(source) }
|
|
28
28
|
end
|
|
29
29
|
end
|
|
30
30
|
end
|
data/lib/cogworker/manager.rb
CHANGED
|
@@ -16,6 +16,7 @@ module Cogworker
|
|
|
16
16
|
# be what's failing (or what lost its last writes in a failover).
|
|
17
17
|
@jobs_mutex = Mutex.new
|
|
18
18
|
@running_jobs = Hash.new(0)
|
|
19
|
+
@performing_jobs = Hash.new(0) # the subset still inside `perform`
|
|
19
20
|
@pending_settlements = {}
|
|
20
21
|
@processors = Array.new(config.concurrency) { Processor.new(self) }
|
|
21
22
|
@quiet = false
|
|
@@ -74,7 +75,24 @@ module Cogworker
|
|
|
74
75
|
end
|
|
75
76
|
|
|
76
77
|
def job_started(raw)
|
|
77
|
-
@jobs_mutex.synchronize
|
|
78
|
+
@jobs_mutex.synchronize do
|
|
79
|
+
@running_jobs[raw] += 1
|
|
80
|
+
@performing_jobs[raw] += 1
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# `perform` (with its middleware) has returned: the job is still this
|
|
85
|
+
# process's until settled, but its locks are no longer its to keep
|
|
86
|
+
# alive — Heartbeat stops refreshing them (see `performing_jobs`).
|
|
87
|
+
def job_performed(raw)
|
|
88
|
+
@jobs_mutex.synchronize do
|
|
89
|
+
@performing_jobs[raw] -= 1
|
|
90
|
+
@performing_jobs.delete(raw) unless @performing_jobs[raw].positive?
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def performing_jobs
|
|
95
|
+
@jobs_mutex.synchronize { @performing_jobs.keys }
|
|
78
96
|
end
|
|
79
97
|
|
|
80
98
|
def job_finished(raw)
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
-- Returns {1, <previous last_slot, or "" if none>} if this call won the
|
|
11
11
|
-- claim for this slot (the caller should enqueue the job — and, if that
|
|
12
12
|
-- fails, roll the claim back with ROLLBACK_SCRIPT using that previous
|
|
13
|
-
-- value), 0 otherwise. Composes two guards: `last_slot` stops a
|
|
13
|
+
-- value), 2 if blocked by the entry's running lock, 0 otherwise. Composes two guards: `last_slot` stops a
|
|
14
14
|
-- process re-firing the same (or an earlier) slot on a later tick, and the
|
|
15
15
|
-- NX lock breaks the narrow race where two processes both pass the
|
|
16
16
|
-- `last_slot` check before either has written it back.
|
|
@@ -18,8 +18,11 @@
|
|
|
18
18
|
-- For `until_executed`, the running lock is written here, in the same
|
|
19
19
|
-- atomic step as the claim and before the job exists anywhere — see
|
|
20
20
|
-- Periodic::RunningLock for why it must never be written after the push.
|
|
21
|
+
-- Blocked by a run still holding the entry's lock: a distinct 2, so the
|
|
22
|
+
-- caller can retry the slot briefly — the lock may only be waiting for a
|
|
23
|
+
-- release delayed by an outage (see Ticker::BLOCKED_GRACE).
|
|
21
24
|
if ARGV[2] == "until_executed" and redis.call("GET", KEYS[1]) then
|
|
22
|
-
return
|
|
25
|
+
return 2
|
|
23
26
|
end
|
|
24
27
|
|
|
25
28
|
local previous = redis.call("GET", KEYS[2])
|
|
@@ -23,14 +23,25 @@ module Cogworker
|
|
|
23
23
|
begin
|
|
24
24
|
yield
|
|
25
25
|
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
26
|
-
|
|
26
|
+
release(pjid, job['jid']) if JobUtil.terminal_failure?(job)
|
|
27
27
|
raise e
|
|
28
28
|
end
|
|
29
|
-
|
|
29
|
+
release(pjid, job['jid'])
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
private
|
|
33
33
|
|
|
34
|
+
# Best-effort — a release that fails (Redis away) never turns the
|
|
35
|
+
# finished run into a failure — and retried later (DeferredReleases)
|
|
36
|
+
# rather than left to the lock's TTL.
|
|
37
|
+
def release(pjid, jid)
|
|
38
|
+
released = BestEffort.call('Periodic lock') do
|
|
39
|
+
RunningLock.release(pjid, jid)
|
|
40
|
+
true
|
|
41
|
+
end
|
|
42
|
+
DeferredReleases.add(RedisKeys.periodic_running(pjid), jid) unless released
|
|
43
|
+
end
|
|
44
|
+
|
|
34
45
|
# Best-effort: failing here must not stop the job from running (it
|
|
35
46
|
# would be routed to retry/dead as if it had failed). If it does fail,
|
|
36
47
|
# the lock just keeps its longer queued TTL until this process's next
|
|
@@ -61,6 +61,18 @@ module Cogworker
|
|
|
61
61
|
OwnedKey.expire(RedisKeys.periodic_running(pjid), jid, ttl, conn)
|
|
62
62
|
end
|
|
63
63
|
|
|
64
|
+
RETAKE_SCRIPT = RETAKE_FUNCTION + <<~LUA
|
|
65
|
+
retake_running_lock(KEYS[1], ARGV[1], ARGV[2])
|
|
66
|
+
return 1
|
|
67
|
+
LUA
|
|
68
|
+
|
|
69
|
+
# Extends the lock if still `jid`'s, takes it if it's gone, leaves it
|
|
70
|
+
# if another run holds it — for a run still going whose lock may have
|
|
71
|
+
# lapsed (Redis away longer than `active_ttl`).
|
|
72
|
+
def retake(pjid, jid, ttl, conn)
|
|
73
|
+
LuaScript.run(conn, RETAKE_SCRIPT, keys: [RedisKeys.periodic_running(pjid)], argv: [jid, ttl.to_f.ceil])
|
|
74
|
+
end
|
|
75
|
+
|
|
64
76
|
def release(pjid, jid, conn = nil)
|
|
65
77
|
OwnedKey.delete(RedisKeys.periodic_running(pjid), jid, conn)
|
|
66
78
|
end
|
|
@@ -29,7 +29,7 @@ module Cogworker
|
|
|
29
29
|
# separate call made first, its failure (Redis down — the usual reason
|
|
30
30
|
# the push failed) skipped the rollback altogether.
|
|
31
31
|
ROLLBACK_SCRIPT = <<~LUA
|
|
32
|
-
if redis.
|
|
32
|
+
if redis.pcall("GET", KEYS[1]) == ARGV[1] then
|
|
33
33
|
if ARGV[2] == "" then
|
|
34
34
|
redis.call("SET", KEYS[1], tostring(tonumber(ARGV[1]) - 1))
|
|
35
35
|
else
|
|
@@ -54,6 +54,7 @@ module Cogworker
|
|
|
54
54
|
@last_checked_slot = {}
|
|
55
55
|
@cron_cache = {}
|
|
56
56
|
@pending_rollbacks = []
|
|
57
|
+
@blocked_since = {} # pjid => [slot, monotonic time it was first found blocked]
|
|
57
58
|
@failures = Hash.new(0) # pjid => consecutive failed ticks
|
|
58
59
|
@retry_at = {} # pjid => monotonic time it may be tried again
|
|
59
60
|
end
|
|
@@ -113,12 +114,16 @@ module Cogworker
|
|
|
113
114
|
|
|
114
115
|
def tick
|
|
115
116
|
retry_pending_rollbacks
|
|
117
|
+
# This process's own delayed lock releases first (see BLOCKED_GRACE).
|
|
118
|
+
BestEffort.call('Deferred lock releases') { Cogworker.config.redis { |c| DeferredReleases.retry_all(c) } }
|
|
116
119
|
now = Time.now
|
|
117
120
|
@entries.each do |entry|
|
|
118
121
|
next if backing_off?(entry.pjid)
|
|
119
122
|
|
|
120
|
-
tick_entry(entry, now)
|
|
123
|
+
next if tick_entry(entry, now) == :skipped # (no Redis call made: proves nothing)
|
|
124
|
+
|
|
121
125
|
@failures.delete(entry.pjid)
|
|
126
|
+
RedisErrors.recovered('Periodic tick')
|
|
122
127
|
rescue StandardError => e
|
|
123
128
|
# Per entry: one entry failing (its keys of the wrong type, its
|
|
124
129
|
# push failing) used to skip every entry after it, on every tick.
|
|
@@ -128,8 +133,8 @@ module Cogworker
|
|
|
128
133
|
# a lost connection, though: that's Redis being away, not the
|
|
129
134
|
# entry failing — backing off every entry on it left cron idle for
|
|
130
135
|
# minutes after Redis came back.
|
|
131
|
-
delay =
|
|
132
|
-
|
|
136
|
+
delay = RedisErrors.unavailable?(e) ? TICK_INTERVAL : back_off(entry.pjid)
|
|
137
|
+
RedisErrors.report('Periodic tick', e) { "#{entry.pjid} failed (next try in #{delay}s)" }
|
|
133
138
|
end
|
|
134
139
|
end
|
|
135
140
|
|
|
@@ -149,12 +154,32 @@ module Cogworker
|
|
|
149
154
|
|
|
150
155
|
def tick_entry(entry, now)
|
|
151
156
|
slot = cron_for(entry).previous_time(now).to_i
|
|
152
|
-
return if @last_checked_slot[entry.pjid] == slot
|
|
157
|
+
return :skipped if @last_checked_slot[entry.pjid] == slot
|
|
153
158
|
|
|
154
159
|
jid = SecureRandom.hex(12)
|
|
155
160
|
previous = claim(entry, slot, jid) unless disabled?(entry)
|
|
156
|
-
|
|
161
|
+
return if previous == :blocked && still_blocked_in_grace?(entry.pjid, slot)
|
|
162
|
+
|
|
163
|
+
enqueue(entry, slot, jid, previous) if previous && previous != :blocked
|
|
157
164
|
@last_checked_slot[entry.pjid] = slot
|
|
165
|
+
@blocked_since.delete(entry.pjid)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# A slot blocked by the entry's running lock is retried for
|
|
169
|
+
# BLOCKED_GRACE before it counts as skipped. After an outage, the lock
|
|
170
|
+
# may still be held only because its finished run's release was
|
|
171
|
+
# deferred (DeferredReleases — retried on the next heartbeat, in
|
|
172
|
+
# whichever process ran it); marking the slot checked straight away
|
|
173
|
+
# lost it, in every process, until the entry's next slot — an hour,
|
|
174
|
+
# for an hourly entry. A run genuinely still going keeps the lock past
|
|
175
|
+
# the grace, and its slot is skipped as `until_executed` intends.
|
|
176
|
+
BLOCKED_GRACE = 3 * Heartbeat::INTERVAL
|
|
177
|
+
|
|
178
|
+
def still_blocked_in_grace?(pjid, slot)
|
|
179
|
+
now = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
180
|
+
since_slot, since = @blocked_since[pjid]
|
|
181
|
+
@blocked_since[pjid] = [slot, since = now] unless since_slot == slot
|
|
182
|
+
now - since < BLOCKED_GRACE
|
|
158
183
|
end
|
|
159
184
|
|
|
160
185
|
# The usual reason a push fails — Redis unreachable — makes this fail
|
|
@@ -175,7 +200,7 @@ module Cogworker
|
|
|
175
200
|
end
|
|
176
201
|
true
|
|
177
202
|
rescue StandardError => e
|
|
178
|
-
|
|
203
|
+
RedisErrors.report('Periodic rollback', e) { "#{entry.pjid}: couldn't roll back slot #{slot} (will retry)" }
|
|
179
204
|
@pending_rollbacks << [entry, slot, previous_slot, jid]
|
|
180
205
|
e # (truthy, but not `true`: the caller tells a lost connection from a failing rollback)
|
|
181
206
|
end
|
|
@@ -194,7 +219,8 @@ module Cogworker
|
|
|
194
219
|
if result == true
|
|
195
220
|
@failures.delete(key)
|
|
196
221
|
@retry_at.delete(key)
|
|
197
|
-
|
|
222
|
+
RedisErrors.recovered('Periodic rollback')
|
|
223
|
+
elsif !RedisErrors.unavailable?(result)
|
|
198
224
|
back_off(key) # only a rollback failing on its own backs off; see #tick
|
|
199
225
|
end
|
|
200
226
|
end
|
|
@@ -218,9 +244,9 @@ module Cogworker
|
|
|
218
244
|
# connection still raises (the tick is then retried as a whole).
|
|
219
245
|
def disabled?(entry)
|
|
220
246
|
Cogworker.config.redis { |c| c.sismember(RedisKeys::PERIODIC_DISABLED, entry.pjid) }
|
|
221
|
-
rescue Redis::BaseConnectionError
|
|
222
|
-
raise
|
|
223
247
|
rescue StandardError => e
|
|
248
|
+
raise if RedisErrors.unavailable?(e)
|
|
249
|
+
|
|
224
250
|
Cogworker.logger.error { "Periodic: can't read #{RedisKeys::PERIODIC_DISABLED} (#{e.class}: #{e.message}); treating #{entry.pjid} as enabled" }
|
|
225
251
|
false
|
|
226
252
|
end
|
|
@@ -229,17 +255,20 @@ module Cogworker
|
|
|
229
255
|
@cron_cache[entry.pjid] ||= Fugit::Cron.parse(entry.cron)
|
|
230
256
|
end
|
|
231
257
|
|
|
232
|
-
# nil if this process didn't win the slot
|
|
233
|
-
#
|
|
258
|
+
# nil if this process didn't win the slot, :blocked if the entry's
|
|
259
|
+
# running lock stopped it; otherwise the previous `last_slot` value
|
|
260
|
+
# ("" if none), for #rollback_claim.
|
|
234
261
|
def claim(entry, slot, jid)
|
|
235
262
|
return nil if !@catch_up && priming_first_slot?(entry, slot)
|
|
236
263
|
|
|
237
264
|
result = Cogworker.config.redis do |c|
|
|
238
265
|
LuaScript.run(c, CLAIM_SCRIPT,
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
266
|
+
keys: [RedisKeys.periodic_running(entry.pjid), RedisKeys.periodic_last_slot(entry.pjid),
|
|
267
|
+
RedisKeys.periodic_lock(entry.pjid, slot)],
|
|
268
|
+
argv: [slot, entry.unique.to_s, LOCK_TTL, jid, RunningLock.queued_ttl])
|
|
242
269
|
end
|
|
270
|
+
return :blocked if result == 2
|
|
271
|
+
|
|
243
272
|
result.is_a?(Array) ? result[1].to_s : nil
|
|
244
273
|
end
|
|
245
274
|
|
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
require 'json'
|
|
4
4
|
|
|
5
5
|
module Cogworker
|
|
6
|
+
# Every live worker process, as published by its Heartbeat — one
|
|
7
|
+
# Cogworker::Process per `cogworker:processes` member whose presence key
|
|
8
|
+
# still exists (stale members are pruned while iterating).
|
|
6
9
|
class ProcessSet
|
|
7
10
|
include Enumerable
|
|
8
11
|
|