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.
- checksums.yaml +4 -4
- data/LICENSE.txt +21 -0
- data/README.md +596 -0
- data/lib/cogworker/basic_fetch.rb +4 -3
- 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 +16 -1
- data/lib/cogworker/deferred_releases.rb +46 -0
- data/lib/cogworker/heartbeat.rb +72 -15
- data/lib/cogworker/history/middleware.rb +9 -5
- data/lib/cogworker/job_util.rb +39 -4
- data/lib/cogworker/key_guard.rb +127 -0
- data/lib/cogworker/launcher.rb +6 -2
- data/lib/cogworker/lua_script.rb +3 -3
- data/lib/cogworker/manager.rb +76 -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 +148 -40
- data/lib/cogworker/process_set.rb +3 -0
- data/lib/cogworker/processor.rb +94 -19
- data/lib/cogworker/redis_errors.rb +93 -0
- data/lib/cogworker/redis_keys.rb +13 -0
- data/lib/cogworker/reliable_fetch.rb +477 -60
- data/lib/cogworker/scheduled.rb +101 -18
- 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 +7 -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
|
@@ -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
|
|
12
|
+
:orphan_threshold, :max_orphanings
|
|
13
13
|
attr_accessor :concurrency, :queues, :periodic_catch_up, :unique_lock_ttl
|
|
14
14
|
|
|
15
15
|
def initialize
|
|
@@ -51,9 +51,24 @@ module Cogworker
|
|
|
51
51
|
# failover, a long GVL-holding call) — each of which runs its jobs
|
|
52
52
|
# twice; lower: a really crashed process's jobs come back sooner.
|
|
53
53
|
@orphan_threshold = 300
|
|
54
|
+
# How many times a job may be recovered from a process that died
|
|
55
|
+
# while running it before it goes to dead instead (ReliableFetch). The
|
|
56
|
+
# count can't single out the culprit: every job running in a process
|
|
57
|
+
# that crashed is counted, so keep this above the number of crashes an
|
|
58
|
+
# innocent job might plausibly sit through.
|
|
59
|
+
@max_orphanings = 3
|
|
54
60
|
register_default_middleware
|
|
55
61
|
end
|
|
56
62
|
|
|
63
|
+
def max_orphanings=(count)
|
|
64
|
+
unless count.is_a?(Integer) && count.positive?
|
|
65
|
+
raise ArgumentError,
|
|
66
|
+
"max_orphanings must be a positive Integer, got #{count.inspect}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
@max_orphanings = count
|
|
70
|
+
end
|
|
71
|
+
|
|
57
72
|
def orphan_threshold=(seconds)
|
|
58
73
|
unless seconds.is_a?(Numeric) && seconds.positive?
|
|
59
74
|
raise ArgumentError, "orphan_threshold must be a positive number of seconds, got #{seconds.inspect}"
|
|
@@ -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
|
|
|
@@ -117,22 +120,57 @@ module Cogworker
|
|
|
117
120
|
'rss_kb' => current_rss_kb
|
|
118
121
|
}
|
|
119
122
|
|
|
123
|
+
# The presence key is what makes this process alive to everyone else
|
|
124
|
+
# (and what Heartbeat#start! waits for) — the one write a beat can't
|
|
125
|
+
# do without. Everything else is written on its own, best-effort: one
|
|
126
|
+
# shared key of the wrong type used to fail every beat of every
|
|
127
|
+
# process, so no new process could start anywhere in the fleet.
|
|
120
128
|
Cogworker.config.redis do |c|
|
|
121
|
-
c.sadd?(RedisKeys::PROCESSES, identity)
|
|
122
129
|
c.hset(RedisKeys.process(identity),
|
|
123
130
|
'info', JSON.generate(info),
|
|
124
131
|
'busy', @manager.busy_count.to_s,
|
|
125
132
|
'quiet', @manager.quiet?.to_s)
|
|
126
133
|
c.expire(RedisKeys.process(identity), TTL)
|
|
127
|
-
c
|
|
128
|
-
if reliable
|
|
129
|
-
c.sadd?(RedisKeys::IN_PROGRESS_IDENTITIES, identity)
|
|
130
|
-
c.hset(RedisKeys::LAST_BEAT, identity, redis_now(c))
|
|
131
|
-
end
|
|
134
|
+
write_registries(c, identity, reliable)
|
|
132
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) }
|
|
133
139
|
end
|
|
134
140
|
end
|
|
135
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
|
+
|
|
166
|
+
def secondary(what)
|
|
167
|
+
yield
|
|
168
|
+
rescue StandardError => e
|
|
169
|
+
raise if RedisErrors.unavailable?(e) # Redis itself is away: the beat as a whole failed
|
|
170
|
+
|
|
171
|
+
Cogworker.logger.error { "Heartbeat: #{what} not written: #{e.class}: #{e.message}" }
|
|
172
|
+
end
|
|
173
|
+
|
|
136
174
|
# Redis's clock, not this host's (so host clock skew doesn't matter to
|
|
137
175
|
# orphan detection) — falling back to this host's where `TIME` isn't
|
|
138
176
|
# allowed (some proxies/managed services): better a skewed stamp than a
|
|
@@ -145,15 +183,33 @@ module Cogworker
|
|
|
145
183
|
|
|
146
184
|
# Keeps every in-flight `until_executed` periodic run's
|
|
147
185
|
# `periodic:running:<pjid>` lock alive for as long as this process is —
|
|
148
|
-
# see Periodic::RunningLock.
|
|
149
|
-
#
|
|
150
|
-
#
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
186
|
+
# see Periodic::RunningLock. From the Manager's own in-memory record of
|
|
187
|
+
# what's running here, not `cogworker:workers` in Redis: a failover that
|
|
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.
|
|
199
|
+
def touch_running_periodic_locks(conn, _identity)
|
|
200
|
+
recovering = @beat_failed
|
|
201
|
+
@manager.performing_jobs.each do |raw|
|
|
202
|
+
job = JSON.parse(raw)
|
|
154
203
|
next unless job.is_a?(Hash) && job['periodic_pjid']
|
|
155
204
|
|
|
156
|
-
|
|
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
|
|
157
213
|
rescue StandardError => e
|
|
158
214
|
# Per entry: one bad entry mustn't leave the others' locks to expire.
|
|
159
215
|
Cogworker.logger.error { "couldn't refresh periodic lock for #{raw.to_s[0, 200]}: #{e.class}: #{e.message}" }
|
|
@@ -186,6 +242,7 @@ module Cogworker
|
|
|
186
242
|
def subscribe_loop
|
|
187
243
|
@subscribe_client = ::Redis.new(RedisConnection.client_options(Cogworker.config.redis_options))
|
|
188
244
|
@subscribe_client.subscribe(RedisKeys.signal(Cogworker.identity)) do |on|
|
|
245
|
+
on.subscribe { |*| RedisErrors.recovered('Signal subscriber') }
|
|
189
246
|
on.message do |_channel, message|
|
|
190
247
|
dispatch(message)
|
|
191
248
|
end
|
|
@@ -193,7 +250,7 @@ module Cogworker
|
|
|
193
250
|
rescue StandardError => e
|
|
194
251
|
return if @stopping
|
|
195
252
|
|
|
196
|
-
|
|
253
|
+
RedisErrors.report('Signal subscriber', e)
|
|
197
254
|
sleep(1)
|
|
198
255
|
retry
|
|
199
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
|
@@ -37,11 +37,18 @@ module Cogworker
|
|
|
37
37
|
# define `#to_i` (`nil.to_i` happens to be `0` — coincidentally correct
|
|
38
38
|
# for absent — but `false.to_i` raises `NoMethodError` outright, which is
|
|
39
39
|
# exactly the bug this method exists to not have).
|
|
40
|
+
#
|
|
41
|
+
# Anything else (a Hash, a non-numeric String…) means no retries — the
|
|
42
|
+
# cautious reading for a job with side effects — rather than raising
|
|
43
|
+
# from the middle of recording its failure.
|
|
40
44
|
def max_retries(job)
|
|
41
|
-
case job['retry']
|
|
45
|
+
case (value = job['retry'])
|
|
42
46
|
when false then 0
|
|
43
47
|
when true, nil then DEFAULT_MAX_RETRY_ATTEMPTS
|
|
44
|
-
|
|
48
|
+
when Integer then value
|
|
49
|
+
when Float then value.to_i
|
|
50
|
+
when String then Integer(value, exception: false) || 0
|
|
51
|
+
else 0
|
|
45
52
|
end
|
|
46
53
|
end
|
|
47
54
|
|
|
@@ -75,12 +82,17 @@ module Cogworker
|
|
|
75
82
|
# commands, a crash or dropped connection between the ZREM and the
|
|
76
83
|
# LPUSH lost the job. The new payload is built in Ruby, not re-encoded
|
|
77
84
|
# by Lua's cjson (which would round large integers in args).
|
|
85
|
+
# Present? Push, then remove — never remove first: a script stopped by
|
|
86
|
+
# an error keeps the writes made before it, so a ZREM followed by a
|
|
87
|
+
# failing LPUSH (the queue key holding the wrong type) lost the entry.
|
|
88
|
+
# The `cogworker:queues` listing is best-effort.
|
|
78
89
|
CLAIM_AND_REQUEUE_SCRIPT = <<~LUA
|
|
79
|
-
if redis.call("
|
|
90
|
+
if not redis.call("ZSCORE", KEYS[1], ARGV[1]) then
|
|
80
91
|
return 0
|
|
81
92
|
end
|
|
82
|
-
redis.call("SADD", KEYS[2], ARGV[2])
|
|
83
93
|
redis.call("LPUSH", KEYS[3], ARGV[3])
|
|
94
|
+
redis.call("ZREM", KEYS[1], ARGV[1])
|
|
95
|
+
redis.pcall("SADD", KEYS[2], ARGV[2])
|
|
84
96
|
return 1
|
|
85
97
|
LUA
|
|
86
98
|
|
|
@@ -120,8 +132,31 @@ module Cogworker
|
|
|
120
132
|
limit ? str[0, limit] : str
|
|
121
133
|
end
|
|
122
134
|
|
|
135
|
+
# Never raises: an exception whose own `#message` raises (a broken
|
|
136
|
+
# custom exception class) used to escape from the middle of recording
|
|
137
|
+
# its failure — and, the job then counting as not-yet-run, it was handed
|
|
138
|
+
# back and re-run about once a second, forever.
|
|
123
139
|
def error_message(error)
|
|
124
140
|
safe_string(error.message, ERROR_MESSAGE_LIMIT)
|
|
141
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
142
|
+
"#<#{error.class}>"
|
|
143
|
+
end
|
|
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
|
|
125
160
|
end
|
|
126
161
|
|
|
127
162
|
UNPARSEABLE_CLASS = '(unparseable)'
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
module Cogworker
|
|
6
|
+
# Finds this gem's Redis keys holding the wrong type of value — which
|
|
7
|
+
# only a manual write or data corruption produces, but which then breaks
|
|
8
|
+
# every command on that key, deterministically: a queue that can't be
|
|
9
|
+
# pushed to, a `dead` set nothing can be filed in, a registry that fails
|
|
10
|
+
# every beat — and moves each such key out of the way, to
|
|
11
|
+
# `cogworker:quarantine:<key>:<time>:<random>` (kept QUARANTINE_TTL for a human to
|
|
12
|
+
# look at), with an error in the log. Everything then carries on with a
|
|
13
|
+
# fresh, empty key of the right type. Run by every process's Scheduled
|
|
14
|
+
# poller at boot and every CHECK_INTERVAL.
|
|
15
|
+
module KeyGuard
|
|
16
|
+
CHECK_INTERVAL = 60
|
|
17
|
+
QUARANTINE_TTL = 30 * 24 * 60 * 60
|
|
18
|
+
QUARANTINE_PREFIX = 'cogworker:quarantine:'
|
|
19
|
+
|
|
20
|
+
FIXED = {
|
|
21
|
+
RedisKeys::SCHEDULE => 'zset', RedisKeys::RETRY => 'zset', RedisKeys::DEAD => 'zset',
|
|
22
|
+
RedisKeys::QUEUES => 'set', RedisKeys::PROCESSES => 'set', RedisKeys::PAUSED_QUEUES => 'set',
|
|
23
|
+
RedisKeys::IN_PROGRESS_IDENTITIES => 'set', RedisKeys::LAST_BEAT => 'hash',
|
|
24
|
+
RedisKeys::REPEAT_ORPHANS => 'list', RedisKeys::UNSETTLED => 'list', RedisKeys::PERIODIC_SCHEDULE => 'hash',
|
|
25
|
+
RedisKeys::LIVE_QUEUES => 'zset',
|
|
26
|
+
RedisKeys::PERIODIC_DISABLED => 'set', RedisKeys::STATS_PROCESSED => 'string',
|
|
27
|
+
RedisKeys::STATS_FAILED => 'string'
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
# KEYS[1] = the key, KEYS[2] = its quarantine name; ARGV[1] = expected
|
|
31
|
+
# type, ARGV[2] = quarantine TTL. Re-checked here, atomically with the
|
|
32
|
+
# move, so a key fixed (or recreated) in between is left alone.
|
|
33
|
+
QUARANTINE_SCRIPT = <<~LUA
|
|
34
|
+
local actual = redis.call("TYPE", KEYS[1])["ok"]
|
|
35
|
+
if actual == "none" or actual == ARGV[1] then
|
|
36
|
+
return 0
|
|
37
|
+
end
|
|
38
|
+
redis.call("RENAME", KEYS[1], KEYS[2])
|
|
39
|
+
redis.call("EXPIRE", KEYS[2], ARGV[2])
|
|
40
|
+
return 1
|
|
41
|
+
LUA
|
|
42
|
+
|
|
43
|
+
module_function
|
|
44
|
+
|
|
45
|
+
# Returns the keys quarantined.
|
|
46
|
+
def check(queues: Cogworker.config.queues)
|
|
47
|
+
expected = expected_types(queues)
|
|
48
|
+
actual = Cogworker.config.redis do |c|
|
|
49
|
+
c.pipelined { |p| expected.each_key { |key| p.type(key) } }
|
|
50
|
+
end
|
|
51
|
+
expected.keys.zip(actual).filter_map do |key, type|
|
|
52
|
+
next if type == 'none' || type == expected[key]
|
|
53
|
+
|
|
54
|
+
key if quarantine(key, expected[key], type)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The fixed keys, plus the per-queue and per-process ones currently
|
|
59
|
+
# listed in them (only where the listing itself is readable).
|
|
60
|
+
def expected_types(queues)
|
|
61
|
+
types = FIXED.dup
|
|
62
|
+
queue_names = Array(queues) | readable_members(RedisKeys::QUEUES)
|
|
63
|
+
queue_names.each { |q| types[RedisKeys.queue(q)] = 'list' }
|
|
64
|
+
readable_members(RedisKeys::IN_PROGRESS_IDENTITIES).each { |id| types[RedisKeys.in_progress(id)] = 'list' }
|
|
65
|
+
readable_members(RedisKeys::PROCESSES).each do |id|
|
|
66
|
+
types[RedisKeys.process(id)] = 'hash'
|
|
67
|
+
types[RedisKeys.workers(id)] = 'hash'
|
|
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
|
|
73
|
+
types
|
|
74
|
+
end
|
|
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
|
+
|
|
104
|
+
def readable_members(set)
|
|
105
|
+
Cogworker.config.redis { |c| c.smembers(set) }
|
|
106
|
+
rescue Redis::CommandError
|
|
107
|
+
[] # itself of the wrong type: quarantined this round, listed again next
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def quarantine(key, expected, actual)
|
|
111
|
+
# Unique per move: a second quarantine of the same key within the same
|
|
112
|
+
# second must not overwrite (RENAME) the first one's copy.
|
|
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)}"
|
|
115
|
+
moved = Cogworker.config.redis do |c|
|
|
116
|
+
LuaScript.run(c, QUARANTINE_SCRIPT, keys: [key, target], argv: [expected, QUARANTINE_TTL])
|
|
117
|
+
end
|
|
118
|
+
return false unless moved == 1
|
|
119
|
+
|
|
120
|
+
prune_copies(key)
|
|
121
|
+
Cogworker.logger.error do
|
|
122
|
+
"#{key} held a #{actual}, not a #{expected}: moved to #{target} (kept #{QUARANTINE_TTL / 86_400} days)"
|
|
123
|
+
end
|
|
124
|
+
true
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
data/lib/cogworker/launcher.rb
CHANGED
|
@@ -13,14 +13,15 @@ module Cogworker
|
|
|
13
13
|
@manager = Manager.new(config)
|
|
14
14
|
@scheduled = Scheduled.new(@manager)
|
|
15
15
|
@heartbeat = Heartbeat.new(@manager)
|
|
16
|
-
# The first tick waits for the first orphan check
|
|
16
|
+
# The first tick waits for the first orphan check that really
|
|
17
|
+
# completed (Scheduled#ready_for_ticker?): after the whole
|
|
17
18
|
# fleet was down longer than a running `until_executed` job's lock
|
|
18
19
|
# lives, the ticker would otherwise claim that entry's next slot before
|
|
19
20
|
# recovery had requeued the interrupted run (and re-taken its lock) —
|
|
20
21
|
# two runs at once.
|
|
21
22
|
@ticker = Periodic::Ticker.new(
|
|
22
23
|
@manager, config.periodic_manager.entries, catch_up: config.periodic_catch_up,
|
|
23
|
-
ready: -> { @scheduled.
|
|
24
|
+
ready: -> { @scheduled.ready_for_ticker? }
|
|
24
25
|
)
|
|
25
26
|
@signal_queue = ::Queue.new
|
|
26
27
|
end
|
|
@@ -44,6 +45,9 @@ module Cogworker
|
|
|
44
45
|
|
|
45
46
|
def stop!
|
|
46
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) } }
|
|
47
51
|
@scheduled.stop!
|
|
48
52
|
@ticker.stop!
|
|
49
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
|
@@ -7,6 +7,17 @@ module Cogworker
|
|
|
7
7
|
def initialize(config = Cogworker.config)
|
|
8
8
|
@config = config
|
|
9
9
|
@fetch_mutex = Mutex.new
|
|
10
|
+
# This process's own record of its jobs (raw payloads, as fetched):
|
|
11
|
+
# running right now (a count — two copies of one payload can run at
|
|
12
|
+
# once), and finished but not yet settled in Redis — `:ack` (the ack
|
|
13
|
+
# failed) or `[set, score, payload]` (the job ran and failed, and
|
|
14
|
+
# filing it in retry/dead failed too). In memory on purpose:
|
|
15
|
+
# ReliableFetch.reconcile trusts it over anything in Redis, which may
|
|
16
|
+
# be what's failing (or what lost its last writes in a failover).
|
|
17
|
+
@jobs_mutex = Mutex.new
|
|
18
|
+
@running_jobs = Hash.new(0)
|
|
19
|
+
@performing_jobs = Hash.new(0) # the subset still inside `perform`
|
|
20
|
+
@pending_settlements = {}
|
|
10
21
|
@processors = Array.new(config.concurrency) { Processor.new(self) }
|
|
11
22
|
@quiet = false
|
|
12
23
|
@stopping = false
|
|
@@ -63,6 +74,51 @@ module Cogworker
|
|
|
63
74
|
@stopping
|
|
64
75
|
end
|
|
65
76
|
|
|
77
|
+
def job_started(raw)
|
|
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 }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def job_finished(raw)
|
|
99
|
+
@jobs_mutex.synchronize do
|
|
100
|
+
@running_jobs[raw] -= 1
|
|
101
|
+
@running_jobs.delete(raw) unless @running_jobs[raw].positive?
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def running_jobs
|
|
106
|
+
@jobs_mutex.synchronize { @running_jobs.keys }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# `how`: :ack, or [set, score, payload] — see ReliableFetch.settle.
|
|
110
|
+
def settle_later(raw, how)
|
|
111
|
+
@jobs_mutex.synchronize { @pending_settlements[raw] = how }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def settled(raw)
|
|
115
|
+
@jobs_mutex.synchronize { @pending_settlements.delete(raw) }
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def pending_settlements
|
|
119
|
+
@jobs_mutex.synchronize { @pending_settlements.dup }
|
|
120
|
+
end
|
|
121
|
+
|
|
66
122
|
def busy_count
|
|
67
123
|
@busy_mutex.synchronize { @busy_count }
|
|
68
124
|
end
|
|
@@ -109,8 +165,27 @@ module Cogworker
|
|
|
109
165
|
end
|
|
110
166
|
end
|
|
111
167
|
|
|
168
|
+
# Jobs that finished but aren't settled in Redis yet are still on the
|
|
169
|
+
# in-progress list. One more try at settling them first; then `except:`
|
|
170
|
+
# drops the ones only waiting for their ack (which is what dropping
|
|
171
|
+
# them is), in the same atomic step as requeueing the rest — a separate
|
|
172
|
+
# ack pass that failed used to leave them to be requeued and run again.
|
|
173
|
+
# Ones that couldn't be filed in retry/dead even now are handed over to
|
|
174
|
+
# UNSETTLED for any live process to file later (ReliableFetch.park_unsettled)
|
|
175
|
+
# — never requeued: they already ran. Any that can't even be handed
|
|
176
|
+
# over are left on the list (`keep:`): orphan recovery requeues them
|
|
177
|
+
# once this process is gone, at most `max_orphanings` times.
|
|
112
178
|
def requeue_unfinished
|
|
113
|
-
|
|
179
|
+
keep = []
|
|
180
|
+
if fetch_class == ReliableFetch
|
|
181
|
+
ReliableFetch.settle_pending(Cogworker.identity, pending_settlements) { |raw| settled(raw) }
|
|
182
|
+
unfiled = pending_settlements.reject { |_raw, how| how == :ack }
|
|
183
|
+
keep = ReliableFetch.park_unsettled(Cogworker.identity, unfiled)
|
|
184
|
+
(unfiled.keys - keep).each { |raw| settled(raw) }
|
|
185
|
+
end
|
|
186
|
+
to_ack = pending_settlements.select { |_raw, how| how == :ack }.keys
|
|
187
|
+
moved = fetch_class.requeue_in_progress(Cogworker.identity, except: to_ack, keep: keep)
|
|
188
|
+
to_ack.each { |raw| settled(raw) }
|
|
114
189
|
Cogworker.logger.warn { "requeued #{moved} job(s) unfinished at shutdown" } if moved.positive?
|
|
115
190
|
rescue StandardError => e
|
|
116
191
|
Cogworker.logger.error { "Requeueing unfinished jobs failed: #{e.class}: #{e.message}" }
|
|
@@ -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])
|