cogworker 0.2.1 → 0.3.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.
- checksums.yaml +4 -4
- data/lib/cogworker/basic_fetch.rb +37 -2
- data/lib/cogworker/cli.rb +11 -1
- data/lib/cogworker/client.rb +1 -1
- data/lib/cogworker/config.rb +26 -1
- data/lib/cogworker/heartbeat.rb +54 -6
- data/lib/cogworker/job.rb +9 -0
- data/lib/cogworker/job_util.rb +56 -0
- data/lib/cogworker/launcher.rb +20 -1
- data/lib/cogworker/manager.rb +43 -4
- data/lib/cogworker/owned_key.rb +40 -0
- data/lib/cogworker/periodic/claim.lua +9 -0
- data/lib/cogworker/periodic/release_middleware.rb +31 -13
- data/lib/cogworker/periodic/running_lock.rb +44 -0
- data/lib/cogworker/periodic/ticker.rb +39 -10
- data/lib/cogworker/process.rb +2 -2
- data/lib/cogworker/process_set.rb +1 -1
- data/lib/cogworker/processor.rb +116 -14
- data/lib/cogworker/queue.rb +4 -4
- data/lib/cogworker/redis_keys.rb +2 -0
- data/lib/cogworker/reliable_fetch.rb +137 -0
- data/lib/cogworker/scheduled.rb +37 -10
- data/lib/cogworker/stats.rb +1 -1
- data/lib/cogworker/status.rb +5 -1
- data/lib/cogworker/swarm.rb +64 -4
- data/lib/cogworker/testing/empty_queue_error.rb +9 -0
- data/lib/cogworker/testing.rb +27 -3
- data/lib/cogworker/unique_jobs/client_middleware.rb +13 -4
- data/lib/cogworker/unique_jobs/release_middleware.rb +4 -1
- data/lib/cogworker/version.rb +1 -1
- data/lib/cogworker/web/history_query.rb +181 -0
- data/lib/cogworker/web/layout.rb +3 -3
- data/lib/cogworker/web/routes/history.rb +111 -68
- data/lib/cogworker/web/routes/jobs.rb +17 -17
- data/lib/cogworker/web/routes/overview.rb +6 -12
- data/lib/cogworker/web/routes/schedules.rb +2 -2
- data/lib/cogworker/web.rb +21 -8
- data/lib/cogworker.rb +5 -1
- metadata +6 -1
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require 'fugit'
|
|
4
4
|
require 'json'
|
|
5
|
+
require 'securerandom'
|
|
5
6
|
|
|
6
7
|
module Cogworker
|
|
7
8
|
module Periodic
|
|
@@ -54,13 +55,19 @@ module Cogworker
|
|
|
54
55
|
Cogworker.config.redis { |c| c.hset(RedisKeys::PERIODIC_SCHEDULE, *payloads.to_a.flatten) }
|
|
55
56
|
end
|
|
56
57
|
|
|
58
|
+
# A failed tick is logged and retried next interval rather than ending
|
|
59
|
+
# the thread — a single Redis blip used to silently stop every
|
|
60
|
+
# periodic entry in this process for good. A slot whose claim raised
|
|
61
|
+
# isn't recorded in `@last_checked_slot`, so the next tick retries it.
|
|
57
62
|
def run
|
|
58
63
|
until @manager.stopping?
|
|
59
|
-
|
|
64
|
+
begin
|
|
65
|
+
tick unless @manager.quiet?
|
|
66
|
+
rescue StandardError => e
|
|
67
|
+
Cogworker.logger.error { "Periodic tick failed: #{e.class}: #{e.message}" }
|
|
68
|
+
end
|
|
60
69
|
sleep(TICK_INTERVAL)
|
|
61
70
|
end
|
|
62
|
-
rescue StandardError => e
|
|
63
|
-
Cogworker.logger.error { "Periodic ticker died: #{e.class}: #{e.message}" }
|
|
64
71
|
end
|
|
65
72
|
|
|
66
73
|
def tick
|
|
@@ -69,7 +76,8 @@ module Cogworker
|
|
|
69
76
|
slot = cron_for(entry).previous_time(now).to_i
|
|
70
77
|
next if @last_checked_slot[entry.pjid] == slot
|
|
71
78
|
|
|
72
|
-
|
|
79
|
+
jid = SecureRandom.hex(12)
|
|
80
|
+
enqueue(entry, slot, jid) if !disabled?(entry) && claim?(entry, slot, jid)
|
|
73
81
|
@last_checked_slot[entry.pjid] = slot
|
|
74
82
|
end
|
|
75
83
|
end
|
|
@@ -93,14 +101,14 @@ module Cogworker
|
|
|
93
101
|
@cron_cache[entry.pjid] ||= Fugit::Cron.parse(entry.cron)
|
|
94
102
|
end
|
|
95
103
|
|
|
96
|
-
def claim?(entry, slot)
|
|
104
|
+
def claim?(entry, slot, jid)
|
|
97
105
|
return false if !@catch_up && priming_first_slot?(entry, slot)
|
|
98
106
|
|
|
99
107
|
result = Cogworker.config.redis do |c|
|
|
100
108
|
c.eval(CLAIM_SCRIPT,
|
|
101
109
|
keys: [RedisKeys.periodic_running(entry.pjid), RedisKeys.periodic_last_slot(entry.pjid),
|
|
102
110
|
RedisKeys.periodic_lock(entry.pjid, slot)],
|
|
103
|
-
argv: [slot, entry.unique.to_s, LOCK_TTL])
|
|
111
|
+
argv: [slot, entry.unique.to_s, LOCK_TTL, jid, RunningLock.queued_ttl])
|
|
104
112
|
end
|
|
105
113
|
result == 1
|
|
106
114
|
end
|
|
@@ -123,15 +131,36 @@ module Cogworker
|
|
|
123
131
|
Cogworker.config.redis { |c| c.set(RedisKeys.periodic_last_slot(entry.pjid), slot, nx: true) }
|
|
124
132
|
end
|
|
125
133
|
|
|
126
|
-
|
|
134
|
+
# The running lock (for `until_executed`) is already in place, set by
|
|
135
|
+
# the claim under this same `jid` — nothing to write after the push.
|
|
136
|
+
# That relies on the job keeping that jid: `JobUtil.normalize_item`
|
|
137
|
+
# only fills `jid` in when absent, but a custom client middleware that
|
|
138
|
+
# overwrites it would leave the lock owned by a jid no job carries —
|
|
139
|
+
# the entry then stays closed for `RunningLock.queued_ttl` (logged).
|
|
140
|
+
# Only undone here if the push didn't actually produce a job (raised,
|
|
141
|
+
# or a client middleware swallowed it), so a job that never existed
|
|
142
|
+
# can't hold the entry for the whole `RunningLock.queued_ttl`.
|
|
143
|
+
def enqueue(entry, slot, jid)
|
|
127
144
|
job = {
|
|
128
145
|
'class' => entry.class_name, 'args' => entry.args, 'retry' => entry.retry,
|
|
129
|
-
'periodic_pjid' => entry.pjid, 'periodic_slot' => slot
|
|
146
|
+
'periodic_pjid' => entry.pjid, 'periodic_slot' => slot, 'jid' => jid
|
|
130
147
|
}
|
|
131
|
-
|
|
148
|
+
pushed = begin
|
|
149
|
+
Client.push(job)
|
|
150
|
+
rescue StandardError
|
|
151
|
+
RunningLock.release(entry.pjid, jid) if entry.until_executed?
|
|
152
|
+
raise
|
|
153
|
+
end
|
|
132
154
|
return unless entry.until_executed?
|
|
133
155
|
|
|
134
|
-
|
|
156
|
+
if pushed.nil?
|
|
157
|
+
RunningLock.release(entry.pjid, jid)
|
|
158
|
+
elsif pushed != jid
|
|
159
|
+
Cogworker.logger.warn do
|
|
160
|
+
"periodic #{entry.pjid}: a client middleware changed the job's jid (#{jid} -> #{pushed}); its running " \
|
|
161
|
+
"lock won't be released by the job and keeps the entry closed for up to #{RunningLock.queued_ttl}s"
|
|
162
|
+
end
|
|
163
|
+
end
|
|
135
164
|
end
|
|
136
165
|
end
|
|
137
166
|
end
|
data/lib/cogworker/process.rb
CHANGED
|
@@ -6,8 +6,8 @@ module Cogworker
|
|
|
6
6
|
# that's this same process — e.g. WorkerKiller finding itself via
|
|
7
7
|
# ProcessSet — or a genuinely remote one) is subscribed to it and reacts.
|
|
8
8
|
# `#quiet!`/`#stop!` mirror a real `kill -TSTP`/`TERM`; `#resume!` has no
|
|
9
|
-
# OS-signal equivalent (there's no `SIGCONT`-style un-quiet
|
|
10
|
-
#
|
|
9
|
+
# OS-signal equivalent (there's no `SIGCONT`-style un-quiet signal) —
|
|
10
|
+
# it's Cogworker-specific, made possible by
|
|
11
11
|
# `Manager#quiet` being a plain in-memory flag rather than a one-way state
|
|
12
12
|
# transition like `stopping?`.
|
|
13
13
|
#
|
|
@@ -11,7 +11,7 @@ module Cogworker
|
|
|
11
11
|
identities.each do |identity|
|
|
12
12
|
info = Cogworker.config.redis { |c| c.hgetall(RedisKeys.process(identity)) }
|
|
13
13
|
if info.empty?
|
|
14
|
-
Cogworker.config.redis { |c| c.srem(RedisKeys::PROCESSES, identity) }
|
|
14
|
+
Cogworker.config.redis { |c| c.srem?(RedisKeys::PROCESSES, identity) }
|
|
15
15
|
next
|
|
16
16
|
end
|
|
17
17
|
|
data/lib/cogworker/processor.rb
CHANGED
|
@@ -7,43 +7,104 @@ module Cogworker
|
|
|
7
7
|
# One thread of a Manager's pool: fetch -> register in-flight -> run the
|
|
8
8
|
# server middleware chain around perform -> stats/retry -> deregister.
|
|
9
9
|
class Processor
|
|
10
|
+
ERROR_BACKOFF = 1 # seconds
|
|
11
|
+
ACK_ATTEMPTS = 3
|
|
12
|
+
|
|
10
13
|
attr_reader :thread, :tid
|
|
11
14
|
|
|
12
15
|
def initialize(manager)
|
|
13
16
|
@manager = manager
|
|
17
|
+
@busy = false
|
|
14
18
|
@tid = SecureRandom.hex(6)
|
|
15
|
-
@fetcher =
|
|
19
|
+
@fetcher = manager.fetch_class.new(manager.queues)
|
|
16
20
|
end
|
|
17
21
|
|
|
18
22
|
def start!
|
|
19
23
|
@thread = Thread.new { run }
|
|
20
24
|
end
|
|
21
25
|
|
|
26
|
+
# True while executing a job (as opposed to fetching or idle) — what
|
|
27
|
+
# `Manager#stop!` uses to tell a thread that will exit on its own within
|
|
28
|
+
# one fetch timeout from one stuck in a long job.
|
|
29
|
+
def busy?
|
|
30
|
+
@busy
|
|
31
|
+
end
|
|
32
|
+
|
|
22
33
|
private
|
|
23
34
|
|
|
35
|
+
# An error escaping one iteration (a Redis blip in `retrieve_work` or
|
|
36
|
+
# the in-flight bookkeeping) is logged and the loop carries on after
|
|
37
|
+
# ERROR_BACKOFF — it used to end the thread for good, with nothing ever
|
|
38
|
+
# restarting it, so the process silently lost capacity one slot at a
|
|
39
|
+
# time.
|
|
24
40
|
def run
|
|
25
41
|
until @manager.stopping?
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
42
|
+
begin
|
|
43
|
+
process_one
|
|
44
|
+
rescue StandardError => e
|
|
45
|
+
Cogworker.logger.error { "Processor error: #{e.class}: #{e.message}" }
|
|
46
|
+
sleep(ERROR_BACKOFF)
|
|
29
47
|
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
30
50
|
|
|
31
|
-
|
|
32
|
-
|
|
51
|
+
def process_one
|
|
52
|
+
if @manager.quiet?
|
|
53
|
+
sleep(0.5)
|
|
54
|
+
return
|
|
55
|
+
end
|
|
33
56
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
57
|
+
work = @fetcher.retrieve_work
|
|
58
|
+
return unless work
|
|
59
|
+
|
|
60
|
+
# Fetched after shutdown began (e.g. still blocked in BRPOP when
|
|
61
|
+
# `Manager#stop!` requeued unfinished work, which it then popped right
|
|
62
|
+
# back): hand it back rather than start it, since the process is about
|
|
63
|
+
# to exit underneath it.
|
|
64
|
+
return @fetcher.give_back(work) if @manager.stopping?
|
|
65
|
+
|
|
66
|
+
@manager.processor_busy!
|
|
67
|
+
@busy = true
|
|
68
|
+
begin
|
|
69
|
+
execute(work)
|
|
70
|
+
ensure
|
|
71
|
+
@busy = false
|
|
72
|
+
@manager.processor_idle!
|
|
73
|
+
acknowledge(work)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# A failed ack leaves an already-finished job on this process's own
|
|
78
|
+
# in-progress list, where `Manager#stop!` would later requeue it — a
|
|
79
|
+
# silent second run. Retried briefly, then logged with the jid so that
|
|
80
|
+
# duplicate can at least be traced.
|
|
81
|
+
def acknowledge(work)
|
|
82
|
+
attempts = 0
|
|
83
|
+
begin
|
|
84
|
+
attempts += 1
|
|
85
|
+
@fetcher.acknowledge(work)
|
|
86
|
+
rescue StandardError => e
|
|
87
|
+
if attempts < ACK_ATTEMPTS
|
|
88
|
+
sleep(0.1 * attempts)
|
|
89
|
+
retry
|
|
90
|
+
end
|
|
91
|
+
Cogworker.logger.error do
|
|
92
|
+
"couldn't acknowledge finished job jid=#{jid_of(work)} after #{attempts} attempts " \
|
|
93
|
+
"(it may run again on shutdown): #{e.class}: #{e.message}"
|
|
39
94
|
end
|
|
40
95
|
end
|
|
41
|
-
|
|
42
|
-
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def jid_of(work)
|
|
99
|
+
JSON.parse(work.raw_job)['jid']
|
|
100
|
+
rescue StandardError
|
|
101
|
+
'?'
|
|
43
102
|
end
|
|
44
103
|
|
|
45
104
|
def execute(work)
|
|
46
|
-
job =
|
|
105
|
+
job = parse_job(work)
|
|
106
|
+
return unless job
|
|
107
|
+
|
|
47
108
|
register_in_workers(work.queue, job)
|
|
48
109
|
Cogworker.logger.info { "start: #{job['class']} jid=#{job['jid']}" }
|
|
49
110
|
|
|
@@ -89,6 +150,30 @@ module Cogworker
|
|
|
89
150
|
deregister_from_workers
|
|
90
151
|
end
|
|
91
152
|
|
|
153
|
+
# A payload that isn't a JSON object goes straight to `dead`
|
|
154
|
+
# (`JobUtil.unparseable_job`), and `nil` tells `execute` to skip it.
|
|
155
|
+
# Before this, `JSON.parse` raised outside any rescue: the job was lost
|
|
156
|
+
# and the processor thread died with it.
|
|
157
|
+
def parse_job(work)
|
|
158
|
+
job = JSON.parse(work.raw_job)
|
|
159
|
+
raise JSON::ParserError, "expected a JSON object, got #{job.class}" unless job.is_a?(Hash)
|
|
160
|
+
|
|
161
|
+
job
|
|
162
|
+
rescue JSON::ParserError => e
|
|
163
|
+
bury_unparseable(work, e)
|
|
164
|
+
nil
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def bury_unparseable(work, error)
|
|
168
|
+
job = JobUtil.unparseable_job(work.raw_job, queue: work.queue, error: error)
|
|
169
|
+
Cogworker.config.redis do |c|
|
|
170
|
+
c.zadd(RedisKeys::DEAD, job['failed_at'], JSON.generate(job))
|
|
171
|
+
c.incr(RedisKeys::STATS_FAILED)
|
|
172
|
+
end
|
|
173
|
+
Throughput.record('failed')
|
|
174
|
+
Cogworker.logger.error { "unparseable job on queue #{work.queue} moved to dead: #{error.message}" }
|
|
175
|
+
end
|
|
176
|
+
|
|
92
177
|
def build_worker(job)
|
|
93
178
|
klass = Object.const_get(job['class'])
|
|
94
179
|
worker = klass.new
|
|
@@ -108,6 +193,7 @@ module Cogworker
|
|
|
108
193
|
if new_count <= max_retries
|
|
109
194
|
delay = retry_delay(new_count)
|
|
110
195
|
Cogworker.config.redis { |c| c.zadd(RedisKeys::RETRY, Time.now.to_f + delay, JSON.generate(job)) }
|
|
196
|
+
extend_locks_for_retry(job, delay)
|
|
111
197
|
Attempts.record(job['jid'], attempt: new_count, error: error, outcome: 'retrying')
|
|
112
198
|
else
|
|
113
199
|
Cogworker.config.redis { |c| c.zadd(RedisKeys::DEAD, Time.now.to_f, JSON.generate(job)) }
|
|
@@ -115,6 +201,22 @@ module Cogworker
|
|
|
115
201
|
end
|
|
116
202
|
end
|
|
117
203
|
|
|
204
|
+
# A job waiting out its retry backoff still holds its `until_executed`
|
|
205
|
+
# lock(s) — extended here to cover that whole wait plus the usual
|
|
206
|
+
# `unique_lock_ttl`, since a fixed TTL counted from the original push
|
|
207
|
+
# ran out during a long retry series (the delay grows as `count**4`)
|
|
208
|
+
# and let a duplicate in. Owner-checked, so a lock that has since
|
|
209
|
+
# passed to another jid is left alone.
|
|
210
|
+
def extend_locks_for_retry(job, delay)
|
|
211
|
+
ttl = delay + Cogworker.config.unique_lock_ttl
|
|
212
|
+
Cogworker.config.redis do |c|
|
|
213
|
+
if UniqueJobs.until_executed?(job)
|
|
214
|
+
OwnedKey.expire(RedisKeys.unique_lock(UniqueJobs.digest(job)), job['jid'], ttl, c)
|
|
215
|
+
end
|
|
216
|
+
Periodic::RunningLock.touch(job['periodic_pjid'], job['jid'], ttl, c) if job['periodic_pjid']
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
118
220
|
# Grows with the retry count, with jitter to avoid a thundering herd of
|
|
119
221
|
# retries all landing on the same second.
|
|
120
222
|
def retry_delay(count)
|
data/lib/cogworker/queue.rb
CHANGED
|
@@ -33,8 +33,8 @@ module Cogworker
|
|
|
33
33
|
end
|
|
34
34
|
|
|
35
35
|
# Removes every occurrence of this exact raw job entry (matched by full
|
|
36
|
-
# JSON string, same "raw" identity `Routes::
|
|
37
|
-
# already key their own delete/retry
|
|
36
|
+
# JSON string, same "raw" identity `Routes::Jobs`' dead/retrying
|
|
37
|
+
# actions already key their own delete/retry off).
|
|
38
38
|
def delete(raw)
|
|
39
39
|
Cogworker.config.redis { |c| c.lrem(@key, 0, raw) }
|
|
40
40
|
end
|
|
@@ -49,11 +49,11 @@ module Cogworker
|
|
|
49
49
|
# are untouched and keep arriving via `Client.push`, they just stop
|
|
50
50
|
# being picked up until `resume!`.
|
|
51
51
|
def pause!
|
|
52
|
-
Cogworker.config.redis { |c| c.sadd(RedisKeys::PAUSED_QUEUES, name) }
|
|
52
|
+
Cogworker.config.redis { |c| c.sadd?(RedisKeys::PAUSED_QUEUES, name) }
|
|
53
53
|
end
|
|
54
54
|
|
|
55
55
|
def resume!
|
|
56
|
-
Cogworker.config.redis { |c| c.srem(RedisKeys::PAUSED_QUEUES, name) }
|
|
56
|
+
Cogworker.config.redis { |c| c.srem?(RedisKeys::PAUSED_QUEUES, name) }
|
|
57
57
|
end
|
|
58
58
|
|
|
59
59
|
def paused?
|
data/lib/cogworker/redis_keys.rb
CHANGED
|
@@ -9,6 +9,7 @@ module Cogworker
|
|
|
9
9
|
# that nothing else reads.
|
|
10
10
|
module RedisKeys
|
|
11
11
|
QUEUE_PREFIX = 'cogworker:queue:'
|
|
12
|
+
IN_PROGRESS_PREFIX = 'cogworker:inprogress:'
|
|
12
13
|
QUEUES = 'cogworker:queues'
|
|
13
14
|
SCHEDULE = 'cogworker:schedule'
|
|
14
15
|
RETRY = 'cogworker:retry'
|
|
@@ -26,6 +27,7 @@ module Cogworker
|
|
|
26
27
|
def job_attempts(jid) = "cogworker:job_attempts:#{jid}"
|
|
27
28
|
def process(identity) = "cogworker:process:#{identity}"
|
|
28
29
|
def workers(identity) = "cogworker:workers:#{identity}"
|
|
30
|
+
def in_progress(identity) = "#{IN_PROGRESS_PREFIX}#{identity}"
|
|
29
31
|
def signal(identity) = "cogworker:signal:#{identity}"
|
|
30
32
|
def periodic_running(pjid) = "periodic:running:#{pjid}"
|
|
31
33
|
def periodic_last_slot(pjid) = "periodic:last_slot:#{pjid}"
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cogworker
|
|
4
|
+
# The default fetch (`config.fetch = :reliable`): every job is moved
|
|
5
|
+
# atomically (`LMOVE`, Redis >= 6.2) from its queue onto this process's own
|
|
6
|
+
# `cogworker:inprogress:<identity>` list, and only removed from there
|
|
7
|
+
# (`acknowledge`) once it has finished — succeeded, or been routed to
|
|
8
|
+
# retry/dead. A job is therefore never only in a processor's memory: if the
|
|
9
|
+
# process dies mid-job (OOM, SIGKILL, a host going away), the job is still
|
|
10
|
+
# on that list, and `recover_orphans` (run by every live process's
|
|
11
|
+
# `Scheduled` poller) puts it back on its queue once the dead process's
|
|
12
|
+
# heartbeat has expired. The trade-off is at-least-once delivery: a job
|
|
13
|
+
# that was partly done when its process died runs again from the start.
|
|
14
|
+
#
|
|
15
|
+
# `LMOVE` can only take from one list at a time, so there's no blocking
|
|
16
|
+
# wait across several weighted queues the way `BasicFetch`'s single
|
|
17
|
+
# `BRPOP` has: instead, one round trip (FETCH_SCRIPT) tries the queues in
|
|
18
|
+
# weighted-shuffled order and takes the first job it finds, and an empty
|
|
19
|
+
# round sleeps EMPTY_POLL_INTERVAL before the next. Weighting and
|
|
20
|
+
# `Queue#pause!` behave exactly as in BasicFetch.
|
|
21
|
+
class ReliableFetch < BasicFetch
|
|
22
|
+
EMPTY_POLL_INTERVAL = 0.25 # seconds; worst-case pickup delay on an idle process
|
|
23
|
+
MIN_REDIS_VERSION = Gem::Version.new('6.2')
|
|
24
|
+
|
|
25
|
+
# KEYS[1..n-1] = queue keys, in the order to try; KEYS[n] = in-progress list.
|
|
26
|
+
FETCH_SCRIPT = <<~LUA
|
|
27
|
+
local dest = KEYS[#KEYS]
|
|
28
|
+
for i = 1, #KEYS - 1 do
|
|
29
|
+
local job = redis.call("LMOVE", KEYS[i], dest, "RIGHT", "LEFT")
|
|
30
|
+
if job then
|
|
31
|
+
return {KEYS[i], job}
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
return false
|
|
35
|
+
LUA
|
|
36
|
+
|
|
37
|
+
# KEYS[1] = an in-progress list; ARGV[1] = queue key prefix; ARGV[2] =
|
|
38
|
+
# queue for a payload whose own `queue` can't be read (the Processor
|
|
39
|
+
# then buries it as unparseable). Oldest first, each onto the end its
|
|
40
|
+
# queue is popped from, so recovered jobs run next, in their original
|
|
41
|
+
# order. Atomic per call: two processes recovering the same list can't
|
|
42
|
+
# both move one job.
|
|
43
|
+
REQUEUE_SCRIPT = <<~LUA
|
|
44
|
+
local moved = 0
|
|
45
|
+
while true do
|
|
46
|
+
local job = redis.call("LPOP", KEYS[1])
|
|
47
|
+
if not job then
|
|
48
|
+
return moved
|
|
49
|
+
end
|
|
50
|
+
local queue = ARGV[2]
|
|
51
|
+
local ok, decoded = pcall(cjson.decode, job)
|
|
52
|
+
if ok and type(decoded) == "table" and type(decoded["queue"]) == "string" then
|
|
53
|
+
queue = decoded["queue"]
|
|
54
|
+
end
|
|
55
|
+
redis.call("RPUSH", ARGV[1] .. queue, job)
|
|
56
|
+
moved = moved + 1
|
|
57
|
+
end
|
|
58
|
+
LUA
|
|
59
|
+
|
|
60
|
+
# KEYS[1] = in-progress list, KEYS[2] = the job's queue; ARGV[1] = job.
|
|
61
|
+
GIVE_BACK_SCRIPT = <<~LUA
|
|
62
|
+
if redis.call("LREM", KEYS[1], 1, ARGV[1]) == 1 then
|
|
63
|
+
redis.call("RPUSH", KEYS[2], ARGV[1])
|
|
64
|
+
end
|
|
65
|
+
LUA
|
|
66
|
+
|
|
67
|
+
class << self
|
|
68
|
+
def supported?
|
|
69
|
+
version = Cogworker.config.redis { |c| c.info('server')['redis_version'] }
|
|
70
|
+
Gem::Version.new(version) >= MIN_REDIS_VERSION
|
|
71
|
+
rescue StandardError
|
|
72
|
+
true # can't tell (Redis not reachable yet): assume a current server
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Puts every job still on `identity`'s in-progress list back on its
|
|
76
|
+
# queue; returns how many. Used for this process's own unfinished jobs
|
|
77
|
+
# on shutdown (`Manager#stop!`) and for dead processes' lists.
|
|
78
|
+
def requeue_in_progress(identity)
|
|
79
|
+
Cogworker.config.redis do |c|
|
|
80
|
+
c.eval(REQUEUE_SCRIPT, keys: [RedisKeys.in_progress(identity)], argv: [RedisKeys::QUEUE_PREFIX, 'default'])
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Requeues the in-progress list of every process whose heartbeat key
|
|
85
|
+
# is gone — i.e. that died without a clean shutdown. A process always
|
|
86
|
+
# writes its heartbeat before its first fetch (`Heartbeat#start!`), so
|
|
87
|
+
# a live process's list is never mistaken for an orphan.
|
|
88
|
+
def recover_orphans
|
|
89
|
+
own = Cogworker.identity
|
|
90
|
+
keys = Cogworker.config.redis { |c| c.scan_each(match: "#{RedisKeys::IN_PROGRESS_PREFIX}*").to_a }
|
|
91
|
+
keys.sum do |key|
|
|
92
|
+
identity = key.delete_prefix(RedisKeys::IN_PROGRESS_PREFIX)
|
|
93
|
+
next 0 if identity == own || Cogworker.config.redis { |c| c.exists?(RedisKeys.process(identity)) }
|
|
94
|
+
|
|
95
|
+
requeue_in_progress(identity).tap do |moved|
|
|
96
|
+
next unless moved.positive?
|
|
97
|
+
|
|
98
|
+
Cogworker.logger.warn { "requeued #{moved} job(s) left in progress by dead process #{identity}" }
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def retrieve_work
|
|
105
|
+
keys = active_keys
|
|
106
|
+
if keys.empty?
|
|
107
|
+
sleep(TIMEOUT)
|
|
108
|
+
return nil
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
result = Cogworker.config.redis do |c|
|
|
112
|
+
c.eval(FETCH_SCRIPT, keys: keys.shuffle.uniq + [RedisKeys.in_progress(Cogworker.identity)])
|
|
113
|
+
end
|
|
114
|
+
unless result
|
|
115
|
+
sleep(EMPTY_POLL_INTERVAL)
|
|
116
|
+
return nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
queue_key, raw_job = result
|
|
120
|
+
UnitOfWork.new(queue_key.delete_prefix(RedisKeys::QUEUE_PREFIX), raw_job)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Atomically moves a fetched-but-unstarted job off the in-progress list
|
|
124
|
+
# and back to the end of its queue that's popped next. A no-op if it's
|
|
125
|
+
# no longer in progress (already requeued by `requeue_in_progress`).
|
|
126
|
+
def give_back(work)
|
|
127
|
+
Cogworker.config.redis do |c|
|
|
128
|
+
c.eval(GIVE_BACK_SCRIPT, keys: [RedisKeys.in_progress(Cogworker.identity), RedisKeys.queue(work.queue)],
|
|
129
|
+
argv: [work.raw_job])
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def acknowledge(work)
|
|
134
|
+
Cogworker.config.redis { |c| c.lrem(RedisKeys.in_progress(Cogworker.identity), 1, work.raw_job) }
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
data/lib/cogworker/scheduled.rb
CHANGED
|
@@ -11,6 +11,11 @@ module Cogworker
|
|
|
11
11
|
class Scheduled
|
|
12
12
|
SETS = [RedisKeys::SCHEDULE, RedisKeys::RETRY].freeze
|
|
13
13
|
POLL_INTERVAL = 5
|
|
14
|
+
# How often this poller also looks for jobs left in progress by a dead
|
|
15
|
+
# process (ReliableFetch.recover_orphans) — the first time right away,
|
|
16
|
+
# at boot. Every live process does it; the requeue is atomic, so any
|
|
17
|
+
# number of them doing it at once is safe.
|
|
18
|
+
ORPHAN_CHECK_INTERVAL = 60
|
|
14
19
|
|
|
15
20
|
def initialize(manager)
|
|
16
21
|
@manager = manager
|
|
@@ -34,13 +39,27 @@ module Cogworker
|
|
|
34
39
|
|
|
35
40
|
private
|
|
36
41
|
|
|
42
|
+
# A failed poll is logged and retried next interval rather than ending
|
|
43
|
+
# the thread — a single Redis blip used to stop every scheduled/retry
|
|
44
|
+
# job in this process from ever reaching its queue again.
|
|
37
45
|
def run
|
|
38
46
|
until @manager.stopping?
|
|
39
|
-
|
|
47
|
+
begin
|
|
48
|
+
recover_orphans_if_due
|
|
49
|
+
enqueue_due_jobs unless @manager.quiet?
|
|
50
|
+
rescue StandardError => e
|
|
51
|
+
Cogworker.logger.error { "Scheduled poll failed: #{e.class}: #{e.message}" }
|
|
52
|
+
end
|
|
40
53
|
sleep(POLL_INTERVAL)
|
|
41
54
|
end
|
|
42
|
-
|
|
43
|
-
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def recover_orphans_if_due
|
|
58
|
+
now = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
59
|
+
return if @next_orphan_check && now < @next_orphan_check
|
|
60
|
+
|
|
61
|
+
@next_orphan_check = now + ORPHAN_CHECK_INTERVAL
|
|
62
|
+
ReliableFetch.recover_orphans
|
|
44
63
|
end
|
|
45
64
|
|
|
46
65
|
def enqueue_due_jobs
|
|
@@ -51,17 +70,25 @@ module Cogworker
|
|
|
51
70
|
end
|
|
52
71
|
end
|
|
53
72
|
|
|
73
|
+
# An entry that can't be requeued (not JSON, no queue) is buried in
|
|
74
|
+
# `dead` instead: left in place it would sit first by score, failing
|
|
75
|
+
# every poll and starving everything behind it; raising after the
|
|
76
|
+
# `zrem` used to lose it silently (and cut the rest of this poll short).
|
|
54
77
|
def graduate(set, raw)
|
|
55
78
|
Cogworker.config.redis do |c|
|
|
56
|
-
|
|
57
|
-
next unless won
|
|
79
|
+
next unless JobUtil.claim_and_requeue(c, set, raw) == :invalid
|
|
58
80
|
|
|
59
|
-
|
|
60
|
-
c.multi do |pipeline|
|
|
61
|
-
pipeline.sadd(RedisKeys::QUEUES, job['queue'])
|
|
62
|
-
pipeline.lpush(RedisKeys.queue(job['queue']), raw)
|
|
63
|
-
end
|
|
81
|
+
bury(c, set, raw) if c.zrem(set, raw)
|
|
64
82
|
end
|
|
65
83
|
end
|
|
84
|
+
|
|
85
|
+
def bury(conn, set, raw)
|
|
86
|
+
error = JSON::ParserError.new("not a job (JSON object with a queue) in #{set}")
|
|
87
|
+
job = JobUtil.unparseable_job(raw, queue: nil, error: error)
|
|
88
|
+
conn.zadd(RedisKeys::DEAD, job['failed_at'], JSON.generate(job))
|
|
89
|
+
conn.incr(RedisKeys::STATS_FAILED) # counted like Processor#bury_unparseable
|
|
90
|
+
Throughput.record('failed')
|
|
91
|
+
Cogworker.logger.error { "unparseable entry in #{set} moved to dead: #{raw.to_s[0, 200]}" }
|
|
92
|
+
end
|
|
66
93
|
end
|
|
67
94
|
end
|
data/lib/cogworker/stats.rb
CHANGED
|
@@ -30,7 +30,7 @@ module Cogworker
|
|
|
30
30
|
|
|
31
31
|
# The raw `INFO` reply as a flat Hash (server/clients/memory/stats/...
|
|
32
32
|
# sections all merged together, same as the `redis` gem always returns
|
|
33
|
-
# it) — the Web UI's
|
|
33
|
+
# it) — the Web UI's Overview tab picks a handful of fields (version,
|
|
34
34
|
# uptime, connected clients, memory usage) back out of this itself,
|
|
35
35
|
# rather than this class pre-selecting/renaming them, so a future
|
|
36
36
|
# consumer isn't limited to whatever subset this method chose.
|
data/lib/cogworker/status.rb
CHANGED
|
@@ -29,8 +29,12 @@ module Cogworker
|
|
|
29
29
|
config.server_middleware { |chain| chain.add(ServerMiddleware, expiration.to_i) }
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
+
# A Symbol (`:queued`, `:working`, `:retrying`, `:complete`, `:failed`,
|
|
33
|
+
# or `:stopped`/`:interrupted` if something external wrote those), or
|
|
34
|
+
# `nil` for an unknown/expired jid. `get` below stays the raw stored
|
|
35
|
+
# Hash, string values and all.
|
|
32
36
|
def status(jid)
|
|
33
|
-
Storage.read(jid)&.fetch('status', nil)
|
|
37
|
+
Storage.read(jid)&.fetch('status', nil)&.to_sym
|
|
34
38
|
end
|
|
35
39
|
|
|
36
40
|
def get(jid)
|