cogworker 0.2.2 → 0.4.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 +60 -2
- data/lib/cogworker/cli.rb +11 -1
- data/lib/cogworker/client.rb +1 -1
- data/lib/cogworker/config.rb +54 -1
- data/lib/cogworker/heartbeat.rb +79 -7
- data/lib/cogworker/job.rb +9 -0
- data/lib/cogworker/job_util.rb +61 -0
- data/lib/cogworker/launcher.rb +38 -1
- data/lib/cogworker/logging.rb +4 -0
- data/lib/cogworker/manager.rb +66 -4
- data/lib/cogworker/owned_key.rb +40 -0
- data/lib/cogworker/periodic/claim.lua +16 -4
- data/lib/cogworker/periodic/release_middleware.rb +31 -13
- data/lib/cogworker/periodic/running_lock.rb +69 -0
- data/lib/cogworker/periodic/ticker.rb +78 -13
- data/lib/cogworker/process.rb +2 -2
- data/lib/cogworker/process_set.rb +1 -1
- data/lib/cogworker/processor.rb +144 -14
- data/lib/cogworker/queue.rb +4 -4
- data/lib/cogworker/redis_keys.rb +9 -0
- data/lib/cogworker/reliable_fetch/unsupported_error.rb +11 -0
- data/lib/cogworker/reliable_fetch.rb +225 -0
- data/lib/cogworker/scheduled.rb +58 -10
- data/lib/cogworker/stats.rb +1 -1
- data/lib/cogworker/status.rb +5 -1
- data/lib/cogworker/swarm.rb +65 -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/layout.rb +3 -3
- data/lib/cogworker/web/optional_session.rb +25 -0
- data/lib/cogworker/web/routes/jobs.rb +72 -37
- data/lib/cogworker/web/routes/overview.rb +6 -12
- data/lib/cogworker/web/routes/schedules.rb +2 -2
- data/lib/cogworker/web.rb +36 -10
- data/lib/cogworker.rb +16 -1
- metadata +7 -1
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest/sha1'
|
|
4
|
+
|
|
5
|
+
module Cogworker
|
|
6
|
+
# The default fetch (`config.fetch = :reliable`): every job is moved
|
|
7
|
+
# atomically (`LMOVE`, Redis >= 6.2) from its queue onto this process's own
|
|
8
|
+
# `cogworker:inprogress:<identity>` list, and only removed from there
|
|
9
|
+
# (`acknowledge`) once it has finished — succeeded, or been routed to
|
|
10
|
+
# retry/dead. A job is therefore never only in a processor's memory: if the
|
|
11
|
+
# process dies mid-job (OOM, SIGKILL, a host going away), the job is still
|
|
12
|
+
# on that list, and `recover_orphans` (run by every live process's
|
|
13
|
+
# `Scheduled` poller) puts it back on its queue once the dead process has
|
|
14
|
+
# gone `config.orphan_threshold` (default 5 min) without a heartbeat. The trade-off is at-least-once delivery: a job
|
|
15
|
+
# that was partly done when its process died runs again from the start.
|
|
16
|
+
#
|
|
17
|
+
# `LMOVE` can only take from one list at a time, so there's no blocking
|
|
18
|
+
# wait across several weighted queues the way `BasicFetch`'s single
|
|
19
|
+
# `BRPOP` has: instead, one round trip (FETCH_SCRIPT) tries the queues in
|
|
20
|
+
# weighted-shuffled order and takes the first job it finds, and an empty
|
|
21
|
+
# round sleeps EMPTY_POLL_INTERVAL before the next. Weighting and
|
|
22
|
+
# `Queue#pause!` behave exactly as in BasicFetch.
|
|
23
|
+
class ReliableFetch < BasicFetch
|
|
24
|
+
# The first pause after an empty round; each further empty round in a
|
|
25
|
+
# row doubles it, up to `config.fetch_idle_max_interval` (default 1s),
|
|
26
|
+
# and finding a job resets it. That cap is the worst-case pickup delay
|
|
27
|
+
# on an idle process — the trade-off against how often idle processors
|
|
28
|
+
# poll Redis (at 0.25s flat, 4 calls/s per thread).
|
|
29
|
+
EMPTY_POLL_INTERVAL = 0.25
|
|
30
|
+
MIN_REDIS_VERSION = Gem::Version.new('6.2')
|
|
31
|
+
|
|
32
|
+
# KEYS[1..n-1] = queue keys, in the order to try; KEYS[n] = in-progress list.
|
|
33
|
+
FETCH_SCRIPT = <<~LUA
|
|
34
|
+
local dest = KEYS[#KEYS]
|
|
35
|
+
for i = 1, #KEYS - 1 do
|
|
36
|
+
local job = redis.call("LMOVE", KEYS[i], dest, "RIGHT", "LEFT")
|
|
37
|
+
if job then
|
|
38
|
+
return {KEYS[i], job}
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
return false
|
|
42
|
+
LUA
|
|
43
|
+
|
|
44
|
+
# The hot path runs this by hash (EVALSHA), not by shipping the whole
|
|
45
|
+
# script text on every poll; see #run_fetch_script.
|
|
46
|
+
FETCH_SCRIPT_SHA = Digest::SHA1.hexdigest(FETCH_SCRIPT)
|
|
47
|
+
|
|
48
|
+
# KEYS[1] = an in-progress list; ARGV[1] = queue key prefix; ARGV[2] =
|
|
49
|
+
# queue for a payload whose own `queue` can't be read (the Processor
|
|
50
|
+
# then buries it as unparseable); ARGV[3] = periodic running-lock key
|
|
51
|
+
# prefix, ARGV[4] = its queued TTL. Oldest first, each onto the end its
|
|
52
|
+
# queue is popped from, so recovered jobs run next, in their original
|
|
53
|
+
# order. Atomic per call: two processes recovering the same list can't
|
|
54
|
+
# both move one job. A job from an `until_executed` periodic entry
|
|
55
|
+
# re-takes its running lock on the way (same rule as
|
|
56
|
+
# Periodic::RunningLock::RETAKE_FUNCTION), so the ticker can't start
|
|
57
|
+
# the next slot alongside the recovered run.
|
|
58
|
+
REQUEUE_SCRIPT = Periodic::RunningLock::RETAKE_FUNCTION + <<~LUA
|
|
59
|
+
local moved = 0
|
|
60
|
+
while true do
|
|
61
|
+
local job = redis.call("LPOP", KEYS[1])
|
|
62
|
+
if not job then
|
|
63
|
+
return moved
|
|
64
|
+
end
|
|
65
|
+
local queue = ARGV[2]
|
|
66
|
+
local ok, decoded = pcall(cjson.decode, job)
|
|
67
|
+
if ok and type(decoded) == "table" then
|
|
68
|
+
if type(decoded["queue"]) == "string" then
|
|
69
|
+
queue = decoded["queue"]
|
|
70
|
+
end
|
|
71
|
+
local pjid, jid = decoded["periodic_pjid"], decoded["jid"]
|
|
72
|
+
if decoded["periodic_until_executed"] == true and type(pjid) == "string" and type(jid) == "string" then
|
|
73
|
+
retake_running_lock(ARGV[3] .. pjid, jid, ARGV[4])
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
redis.call("RPUSH", ARGV[1] .. queue, job)
|
|
77
|
+
moved = moved + 1
|
|
78
|
+
end
|
|
79
|
+
LUA
|
|
80
|
+
|
|
81
|
+
# KEYS[1] = in-progress list, KEYS[2] = the job's queue; ARGV[1] = job.
|
|
82
|
+
GIVE_BACK_SCRIPT = <<~LUA
|
|
83
|
+
if redis.call("LREM", KEYS[1], 1, ARGV[1]) == 1 then
|
|
84
|
+
redis.call("RPUSH", KEYS[2], ARGV[1])
|
|
85
|
+
end
|
|
86
|
+
LUA
|
|
87
|
+
|
|
88
|
+
class << self
|
|
89
|
+
def supported?
|
|
90
|
+
version = Cogworker.config.redis { |c| c.info('server')['redis_version'] }
|
|
91
|
+
Gem::Version.new(version) >= MIN_REDIS_VERSION
|
|
92
|
+
rescue StandardError
|
|
93
|
+
true # can't tell (Redis not reachable yet): assume a current server
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Puts every job still on `identity`'s in-progress list back on its
|
|
97
|
+
# queue; returns how many. Used for this process's own unfinished jobs
|
|
98
|
+
# on shutdown (`Manager#stop!`) and for dead processes' lists.
|
|
99
|
+
def requeue_in_progress(identity)
|
|
100
|
+
Cogworker.config.redis do |c|
|
|
101
|
+
c.eval(REQUEUE_SCRIPT, keys: [RedisKeys.in_progress(identity)],
|
|
102
|
+
argv: [RedisKeys::QUEUE_PREFIX, 'default', RedisKeys.periodic_running(''),
|
|
103
|
+
Periodic::RunningLock.queued_ttl])
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Requeues the in-progress list of every process that has gone
|
|
108
|
+
# `config.orphan_threshold` without a heartbeat — i.e. that died
|
|
109
|
+
# without a clean shutdown (see #dead?). A process always
|
|
110
|
+
# writes its heartbeat before its first fetch (`Heartbeat#start!`), so
|
|
111
|
+
# a live process's list is never mistaken for an orphan. Walks
|
|
112
|
+
# IN_PROGRESS_IDENTITIES (each reliable-fetch process registers itself
|
|
113
|
+
# there on every beat) rather than SCANning the keyspace; `scan: true`
|
|
114
|
+
# (once per process, at boot) also picks up lists from before that
|
|
115
|
+
# set existed.
|
|
116
|
+
def recover_orphans(scan: false)
|
|
117
|
+
own = Cogworker.identity
|
|
118
|
+
candidate_identities(scan: scan).sum do |identity|
|
|
119
|
+
next 0 if identity == own || !dead?(identity)
|
|
120
|
+
|
|
121
|
+
requeue_in_progress(identity).tap do |moved|
|
|
122
|
+
forget_if_empty(identity)
|
|
123
|
+
next unless moved.positive?
|
|
124
|
+
|
|
125
|
+
Cogworker.logger.warn { "requeued #{moved} job(s) left in progress by dead process #{identity}" }
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Its presence key gone (no beat for Heartbeat::TTL) is not enough on
|
|
131
|
+
# its own: a live process that just couldn't beat for a minute (a
|
|
132
|
+
# Redis outage or failover, a long GVL-holding call) would lose its
|
|
133
|
+
# in-progress jobs to a second, parallel run. It also has to have gone
|
|
134
|
+
# `orphan_threshold` since its last recorded beat — compared on
|
|
135
|
+
# Redis's clock on both sides, so host clock skew doesn't matter. No
|
|
136
|
+
# recorded beat at all (a list from before LAST_BEAT existed) falls
|
|
137
|
+
# back to the presence key alone.
|
|
138
|
+
def dead?(identity)
|
|
139
|
+
Cogworker.config.redis do |c|
|
|
140
|
+
next false if c.exists?(RedisKeys.process(identity))
|
|
141
|
+
|
|
142
|
+
last = c.hget(RedisKeys::LAST_BEAT, identity)
|
|
143
|
+
last.nil? || c.time.first - last.to_f > Cogworker.config.orphan_threshold
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def candidate_identities(scan:)
|
|
148
|
+
Cogworker.config.redis do |c|
|
|
149
|
+
identities = c.smembers(RedisKeys::IN_PROGRESS_IDENTITIES)
|
|
150
|
+
next identities unless scan
|
|
151
|
+
|
|
152
|
+
scanned = c.scan_each(match: "#{RedisKeys::IN_PROGRESS_PREFIX}*").map do |key|
|
|
153
|
+
key.delete_prefix(RedisKeys::IN_PROGRESS_PREFIX)
|
|
154
|
+
end
|
|
155
|
+
identities | scanned
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def forget_if_empty(identity)
|
|
160
|
+
Cogworker.config.redis do |c|
|
|
161
|
+
next unless c.llen(RedisKeys.in_progress(identity)).zero?
|
|
162
|
+
|
|
163
|
+
c.srem?(RedisKeys::IN_PROGRESS_IDENTITIES, identity)
|
|
164
|
+
c.hdel(RedisKeys::LAST_BEAT, identity)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def retrieve_work
|
|
170
|
+
keys = active_keys
|
|
171
|
+
if keys.empty?
|
|
172
|
+
sleep(TIMEOUT)
|
|
173
|
+
return nil
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
result = run_fetch_script(keys.shuffle.uniq + [RedisKeys.in_progress(Cogworker.identity)])
|
|
177
|
+
unless result
|
|
178
|
+
idle_sleep
|
|
179
|
+
return nil
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
@idle_interval = nil
|
|
183
|
+
queue_key, raw_job = result
|
|
184
|
+
UnitOfWork.new(queue_key.delete_prefix(RedisKeys::QUEUE_PREFIX), raw_job)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Atomically moves a fetched-but-unstarted job off the in-progress list
|
|
188
|
+
# and back to the end of its queue that's popped next. A no-op if it's
|
|
189
|
+
# no longer in progress (already requeued by `requeue_in_progress`).
|
|
190
|
+
def give_back(work)
|
|
191
|
+
Cogworker.config.redis do |c|
|
|
192
|
+
c.eval(GIVE_BACK_SCRIPT, keys: [RedisKeys.in_progress(Cogworker.identity), RedisKeys.queue(work.queue)],
|
|
193
|
+
argv: [work.raw_job])
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def acknowledge(work)
|
|
198
|
+
Cogworker.config.redis { |c| c.lrem(RedisKeys.in_progress(Cogworker.identity), 1, work.raw_job) }
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
private
|
|
202
|
+
|
|
203
|
+
def run_fetch_script(keys)
|
|
204
|
+
Cogworker.config.redis do |c|
|
|
205
|
+
c.evalsha(FETCH_SCRIPT_SHA, keys: keys)
|
|
206
|
+
rescue Redis::CommandError => e
|
|
207
|
+
raise unless e.message.start_with?('NOSCRIPT')
|
|
208
|
+
|
|
209
|
+
c.eval(FETCH_SCRIPT, keys: keys) # loads it into the script cache for next time
|
|
210
|
+
end
|
|
211
|
+
rescue Redis::CommandError => e
|
|
212
|
+
raise unless e.message.match?(/unknown (redis )?command/i)
|
|
213
|
+
|
|
214
|
+
raise UnsupportedError, e.message
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Jittered (75–100% of the interval) so a process's threads, all idle
|
|
218
|
+
# at once, don't keep polling in lockstep.
|
|
219
|
+
def idle_sleep
|
|
220
|
+
max = Cogworker.config.fetch_idle_max_interval
|
|
221
|
+
@idle_interval = @idle_interval ? [@idle_interval * 2, max].min : [EMPTY_POLL_INTERVAL, max].min
|
|
222
|
+
sleep(@idle_interval * (0.75 + (rand * 0.25)))
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
data/lib/cogworker/scheduled.rb
CHANGED
|
@@ -11,6 +11,29 @@ 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, whatever its own fetch mode: in
|
|
17
|
+
# a mixed fleet, or after a runtime fall-back to `:basic`, the lists of
|
|
18
|
+
# crashed reliable processes still need someone to requeue them, and
|
|
19
|
+
# with nothing registered it's a single SMEMBERS. Only the one-off
|
|
20
|
+
# keyspace SCAN (for lists from before IN_PROGRESS_IDENTITIES existed)
|
|
21
|
+
# is limited to processes actually using ReliableFetch. The requeue is
|
|
22
|
+
# atomic, so any number of processes doing it at once is safe.
|
|
23
|
+
ORPHAN_CHECK_INTERVAL = 60
|
|
24
|
+
|
|
25
|
+
# KEYS[1] = the ZSET, KEYS[2] = cogworker:dead, KEYS[3] = stats:failed;
|
|
26
|
+
# ARGV[1] = the entry, ARGV[2] = its dead-set score, ARGV[3] = the
|
|
27
|
+
# wrapper to store. Same claim-and-move-atomically shape as
|
|
28
|
+
# JobUtil::CLAIM_AND_REQUEUE_SCRIPT.
|
|
29
|
+
BURY_SCRIPT = <<~LUA
|
|
30
|
+
if redis.call("ZREM", KEYS[1], ARGV[1]) == 0 then
|
|
31
|
+
return 0
|
|
32
|
+
end
|
|
33
|
+
redis.call("ZADD", KEYS[2], ARGV[2], ARGV[3])
|
|
34
|
+
redis.call("INCR", KEYS[3])
|
|
35
|
+
return 1
|
|
36
|
+
LUA
|
|
14
37
|
|
|
15
38
|
def initialize(manager)
|
|
16
39
|
@manager = manager
|
|
@@ -34,13 +57,28 @@ module Cogworker
|
|
|
34
57
|
|
|
35
58
|
private
|
|
36
59
|
|
|
60
|
+
# A failed poll is logged and retried next interval rather than ending
|
|
61
|
+
# the thread — a single Redis blip used to stop every scheduled/retry
|
|
62
|
+
# job in this process from ever reaching its queue again.
|
|
37
63
|
def run
|
|
38
64
|
until @manager.stopping?
|
|
39
|
-
|
|
65
|
+
begin
|
|
66
|
+
recover_orphans_if_due
|
|
67
|
+
enqueue_due_jobs unless @manager.quiet?
|
|
68
|
+
rescue StandardError => e
|
|
69
|
+
Cogworker.logger.error { "Scheduled poll failed: #{e.class}: #{e.message}" }
|
|
70
|
+
end
|
|
40
71
|
sleep(POLL_INTERVAL)
|
|
41
72
|
end
|
|
42
|
-
|
|
43
|
-
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def recover_orphans_if_due
|
|
76
|
+
now = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
77
|
+
return if @next_orphan_check && now < @next_orphan_check
|
|
78
|
+
|
|
79
|
+
scan = @next_orphan_check.nil? && @manager.fetch_class == ReliableFetch
|
|
80
|
+
@next_orphan_check = now + ORPHAN_CHECK_INTERVAL
|
|
81
|
+
ReliableFetch.recover_orphans(scan: scan)
|
|
44
82
|
end
|
|
45
83
|
|
|
46
84
|
def enqueue_due_jobs
|
|
@@ -51,17 +89,27 @@ module Cogworker
|
|
|
51
89
|
end
|
|
52
90
|
end
|
|
53
91
|
|
|
92
|
+
# An entry that can't be requeued (not JSON, no queue) is buried in
|
|
93
|
+
# `dead` instead: left in place it would sit first by score, failing
|
|
94
|
+
# every poll and starving everything behind it; raising after the
|
|
95
|
+
# `zrem` used to lose it silently (and cut the rest of this poll short).
|
|
54
96
|
def graduate(set, raw)
|
|
55
97
|
Cogworker.config.redis do |c|
|
|
56
|
-
|
|
57
|
-
next unless won
|
|
98
|
+
next unless JobUtil.claim_and_requeue(c, set, raw) == :invalid
|
|
58
99
|
|
|
59
|
-
|
|
60
|
-
c.multi do |pipeline|
|
|
61
|
-
pipeline.sadd(RedisKeys::QUEUES, job['queue'])
|
|
62
|
-
pipeline.lpush(RedisKeys.queue(job['queue']), raw)
|
|
63
|
-
end
|
|
100
|
+
bury(c, set, raw)
|
|
64
101
|
end
|
|
65
102
|
end
|
|
103
|
+
|
|
104
|
+
def bury(conn, set, raw)
|
|
105
|
+
error = JSON::ParserError.new("not a job (JSON object with a queue) in #{set}")
|
|
106
|
+
job = JobUtil.unparseable_job(raw, queue: nil, error: error)
|
|
107
|
+
buried = conn.eval(BURY_SCRIPT, keys: [set, RedisKeys::DEAD, RedisKeys::STATS_FAILED],
|
|
108
|
+
argv: [raw, job['failed_at'], JSON.generate(job)])
|
|
109
|
+
return unless buried == 1
|
|
110
|
+
|
|
111
|
+
Throughput.record('failed') # counted like Processor#bury_unparseable
|
|
112
|
+
Cogworker.logger.error { "unparseable entry in #{set} moved to dead: #{raw.to_s[0, 200]}" }
|
|
113
|
+
end
|
|
66
114
|
end
|
|
67
115
|
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)
|
data/lib/cogworker/swarm.rb
CHANGED
|
@@ -20,14 +20,30 @@ module Cogworker
|
|
|
20
20
|
# that never exits (a hung Redis call outliving even Manager#stop!'s
|
|
21
21
|
# own 25s join, or anything else) must not be able to wedge the whole
|
|
22
22
|
# restart cycle, and the supervisor's entire signal-handling loop with
|
|
23
|
-
# it, forever.
|
|
24
|
-
|
|
23
|
+
# it, forever. Kept well above a child's own worst-case clean shutdown
|
|
24
|
+
# (Manager#stop!'s 25s drain + up to BasicFetch::TIMEOUT + 1 waiting out
|
|
25
|
+
# fetching threads + ~1s for Heartbeat#stop!) so a child is never killed
|
|
26
|
+
# mid-`requeue_unfinished` — for `:basic`, that would lose the jobs.
|
|
27
|
+
GRACEFUL_STOP_TIMEOUT = 35
|
|
28
|
+
|
|
29
|
+
# Respawn backoff for a child that exits unexpectedly: none for the
|
|
30
|
+
# first crash after a healthy run, then doubling from
|
|
31
|
+
# RESPAWN_BASE_DELAY up to RESPAWN_MAX_DELAY while the slot keeps dying
|
|
32
|
+
# within HEALTHY_UPTIME of starting. Without it, a child crashing during
|
|
33
|
+
# boot (bad deploy, Redis unreachable, a typo in the init file) made the
|
|
34
|
+
# supervisor fork/crash in a tight loop forever.
|
|
35
|
+
HEALTHY_UPTIME = 30
|
|
36
|
+
RESPAWN_BASE_DELAY = 1
|
|
37
|
+
RESPAWN_MAX_DELAY = 60
|
|
25
38
|
|
|
26
39
|
def initialize(argv, count: ENV.fetch('COGWORKER_COUNT', 1).to_i, phased: ENV['PHASED_RESTART'] == 'true')
|
|
27
40
|
@argv = argv
|
|
28
41
|
@count = [count, 1].max
|
|
29
42
|
@phased = phased
|
|
30
43
|
@children = {} # pid => slot index
|
|
44
|
+
@started_at = {} # slot => monotonic time of its current child's fork
|
|
45
|
+
@crash_streak = Hash.new(0) # slot => consecutive quick crashes
|
|
46
|
+
@pending_respawns = {} # slot => monotonic time it may be forked again
|
|
31
47
|
@signal_queue = ::Queue.new
|
|
32
48
|
@stopping = false
|
|
33
49
|
end
|
|
@@ -53,9 +69,11 @@ module Cogworker
|
|
|
53
69
|
# indefinitely, so this OS process would never actually go away —
|
|
54
70
|
# and `Swarm#initiate_restart`'s own `Process.waitpid(pid)` for this
|
|
55
71
|
# exact child would then block forever right along with it.
|
|
72
|
+
Cogworker.flush_output!
|
|
56
73
|
::Process.exit!(true)
|
|
57
74
|
end
|
|
58
75
|
@children[pid] = slot
|
|
76
|
+
@started_at[slot] = monotonic_now
|
|
59
77
|
Cogworker.logger.info { "swarm: started child pid=#{pid} slot=#{slot}" }
|
|
60
78
|
end
|
|
61
79
|
|
|
@@ -70,13 +88,16 @@ module Cogworker
|
|
|
70
88
|
until @stopping && @children.empty?
|
|
71
89
|
drain_signals
|
|
72
90
|
reap_children
|
|
91
|
+
respawn_due_children
|
|
92
|
+
kill_stragglers
|
|
73
93
|
sleep 0.2
|
|
74
94
|
end
|
|
75
95
|
end
|
|
76
96
|
|
|
77
97
|
def drain_signals
|
|
78
98
|
until @signal_queue.empty?
|
|
79
|
-
|
|
99
|
+
signal = @signal_queue.pop
|
|
100
|
+
case signal
|
|
80
101
|
when :quiet then relay(Signals::QUIET)
|
|
81
102
|
when :stop then initiate_stop
|
|
82
103
|
when :restart
|
|
@@ -93,9 +114,25 @@ module Cogworker
|
|
|
93
114
|
|
|
94
115
|
def initiate_stop
|
|
95
116
|
@stopping = true
|
|
117
|
+
@stop_deadline = monotonic_now + GRACEFUL_STOP_TIMEOUT
|
|
118
|
+
@pending_respawns.clear
|
|
96
119
|
relay(Signals::STOP)
|
|
97
120
|
end
|
|
98
121
|
|
|
122
|
+
# Same bound as a phased restart's `wait_for_exit`: a child that hasn't
|
|
123
|
+
# exited GRACEFUL_STOP_TIMEOUT after the stop was relayed is
|
|
124
|
+
# force-killed (once), so one hung child can't keep the supervisor —
|
|
125
|
+
# and whatever is waiting on it — alive forever.
|
|
126
|
+
def kill_stragglers
|
|
127
|
+
return unless @stop_deadline && monotonic_now > @stop_deadline
|
|
128
|
+
|
|
129
|
+
@stop_deadline = nil
|
|
130
|
+
@children.each_key do |pid|
|
|
131
|
+
Cogworker.logger.warn { "swarm: child pid=#{pid} didn't stop within #{GRACEFUL_STOP_TIMEOUT}s, killing" }
|
|
132
|
+
safe_kill(pid, 'KILL')
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
99
136
|
# Non-phased: signal every child at once; the normal reap/respawn path
|
|
100
137
|
# below brings each one back as it exits, so there's a brief capacity
|
|
101
138
|
# dip but no explicit special-casing needed. Phased: replace children
|
|
@@ -155,7 +192,31 @@ module Cogworker
|
|
|
155
192
|
if @stopping
|
|
156
193
|
Cogworker.logger.info { "swarm: child pid=#{pid} slot=#{slot} exited" }
|
|
157
194
|
else
|
|
158
|
-
|
|
195
|
+
schedule_respawn(pid, slot)
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def schedule_respawn(pid, slot)
|
|
200
|
+
uptime = monotonic_now - @started_at.fetch(slot, monotonic_now)
|
|
201
|
+
@crash_streak[slot] = uptime < HEALTHY_UPTIME ? @crash_streak[slot] + 1 : 0
|
|
202
|
+
delay = respawn_delay(@crash_streak[slot])
|
|
203
|
+
Cogworker.logger.warn do
|
|
204
|
+
"swarm: child pid=#{pid} slot=#{slot} exited unexpectedly after #{uptime.round(1)}s, " \
|
|
205
|
+
"respawning in #{delay}s"
|
|
206
|
+
end
|
|
207
|
+
@pending_respawns[slot] = monotonic_now + delay
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def respawn_delay(streak)
|
|
211
|
+
return 0 if streak <= 1
|
|
212
|
+
|
|
213
|
+
[RESPAWN_BASE_DELAY * (2**(streak - 2)), RESPAWN_MAX_DELAY].min
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def respawn_due_children
|
|
217
|
+
now = monotonic_now
|
|
218
|
+
@pending_respawns.select { |_slot, at| at <= now }.each_key do |slot|
|
|
219
|
+
@pending_respawns.delete(slot)
|
|
159
220
|
fork_child(slot)
|
|
160
221
|
end
|
|
161
222
|
end
|
data/lib/cogworker/testing.rb
CHANGED
|
@@ -14,8 +14,9 @@ module Cogworker
|
|
|
14
14
|
# but the normalized job hash is appended to `jobs_for(klass_name)`
|
|
15
15
|
# instead of touching Redis — nothing is scheduled or executed. Use
|
|
16
16
|
# `SomeJob.jobs` (added to `Job::ClassMethods`) to assert what got
|
|
17
|
-
# pushed, `SomeJob.
|
|
18
|
-
#
|
|
17
|
+
# pushed, `SomeJob.perform_one`/`SomeJob.drain`/`Testing.drain_all` to
|
|
18
|
+
# then actually run them, `SomeJob.clear`/`Testing.clear_jobs!` to reset
|
|
19
|
+
# between examples.
|
|
19
20
|
# - `:inline` — the job runs synchronously, right here in the calling
|
|
20
21
|
# thread, through the real server middleware chain
|
|
21
22
|
# (`Cogworker.config.server_chain`) — same as `Processor#execute`, minus
|
|
@@ -75,8 +76,31 @@ module Cogworker
|
|
|
75
76
|
registry.clear
|
|
76
77
|
end
|
|
77
78
|
|
|
79
|
+
# Fake mode's "now run them": `perform_one` takes the oldest recorded
|
|
80
|
+
# job for a class off its fake queue and runs it via `perform_inline`
|
|
81
|
+
# (real server middleware chain, errors propagate); `drain` repeats
|
|
82
|
+
# until that class's queue is empty, `drain_all` until every class's
|
|
83
|
+
# is — both also running whatever those jobs push in turn. Also
|
|
84
|
+
# reachable as `SomeJob.perform_one`/`SomeJob.drain`.
|
|
85
|
+
def perform_one(klass_name)
|
|
86
|
+
job = jobs_for(klass_name).shift
|
|
87
|
+
raise EmptyQueueError, "no #{klass_name} jobs recorded" unless job
|
|
88
|
+
|
|
89
|
+
perform_inline(job)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def drain(klass_name)
|
|
93
|
+
perform_one(klass_name) until jobs_for(klass_name).empty?
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def drain_all
|
|
97
|
+
while (klass_name = registry.find { |_name, jobs| jobs.any? }&.first)
|
|
98
|
+
drain(klass_name)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
78
102
|
# Runs one job synchronously through the real server middleware chain.
|
|
79
|
-
#
|
|
103
|
+
# Called from `Client.push` while `inline?`, and by `perform_one`.
|
|
80
104
|
def perform_inline(job)
|
|
81
105
|
klass = Object.const_get(job['class'])
|
|
82
106
|
worker = klass.new
|
|
@@ -9,15 +9,14 @@ module Cogworker
|
|
|
9
9
|
# continue down the chain to the real push. A job that loses the claim
|
|
10
10
|
# (another copy is already enqueued/scheduled/running) never reaches
|
|
11
11
|
# `dispatch` at all — it sets `job['unique_skipped']` instead, which
|
|
12
|
-
# `Client.push` reads to return `nil` rather than a jid
|
|
13
|
-
# "nothing was actually pushed" signal
|
|
12
|
+
# `Client.push` reads to return `nil` rather than a jid — the
|
|
13
|
+
# "nothing was actually pushed" signal to the caller.
|
|
14
14
|
class ClientMiddleware
|
|
15
15
|
def call(_worker_class, job, _queue, redis_pool = Cogworker.config.redis_pool)
|
|
16
16
|
return yield unless UniqueJobs.until_executed?(job)
|
|
17
17
|
|
|
18
18
|
acquired = redis_pool.with do |c|
|
|
19
|
-
c.set(RedisKeys.unique_lock(UniqueJobs.digest(job)), job['jid'],
|
|
20
|
-
nx: true, ex: Cogworker.config.unique_lock_ttl)
|
|
19
|
+
c.set(RedisKeys.unique_lock(UniqueJobs.digest(job)), job['jid'], nx: true, ex: lock_ttl(job))
|
|
21
20
|
end
|
|
22
21
|
|
|
23
22
|
if acquired
|
|
@@ -26,6 +25,16 @@ module Cogworker
|
|
|
26
25
|
job['unique_skipped'] = true
|
|
27
26
|
end
|
|
28
27
|
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
# Counted from when the job is due, not from now: a `perform_in`/
|
|
32
|
+
# `perform_at` further out than `unique_lock_ttl` used to lose its
|
|
33
|
+
# lock before it even ran, letting a duplicate in.
|
|
34
|
+
def lock_ttl(job)
|
|
35
|
+
wait = job['at'] ? [job['at'].to_f - Time.now.to_f, 0].max : 0
|
|
36
|
+
(Cogworker.config.unique_lock_ttl + wait).ceil
|
|
37
|
+
end
|
|
29
38
|
end
|
|
30
39
|
end
|
|
31
40
|
end
|
|
@@ -22,8 +22,11 @@ module Cogworker
|
|
|
22
22
|
|
|
23
23
|
private
|
|
24
24
|
|
|
25
|
+
# Owner-checked: if this job's lock already expired and passed to a
|
|
26
|
+
# newer copy, this must not release *that* one's lock (which would let
|
|
27
|
+
# a third copy in while the newer one is still running).
|
|
25
28
|
def release(job)
|
|
26
|
-
|
|
29
|
+
OwnedKey.delete(RedisKeys.unique_lock(UniqueJobs.digest(job)), job['jid'])
|
|
27
30
|
end
|
|
28
31
|
end
|
|
29
32
|
end
|
data/lib/cogworker/version.rb
CHANGED
data/lib/cogworker/web/layout.rb
CHANGED
|
@@ -178,8 +178,8 @@ module Cogworker
|
|
|
178
178
|
end
|
|
179
179
|
|
|
180
180
|
# Cluster-wide "quiet every process" (`Routes::Workers`'s own `POST
|
|
181
|
-
# /workers/pause_all`) — the mock's "Pause intake" button.
|
|
182
|
-
#
|
|
181
|
+
# /workers/pause_all`) — the mock's "Pause intake" button. Quiet isn't
|
|
182
|
+
# a one-way trip that only a fresh process can undo:
|
|
183
183
|
# `Manager#quiet` here is a plain in-memory flag, so `resume_intake_
|
|
184
184
|
# button` below is a genuine, symmetric undo rather than a full
|
|
185
185
|
# process restart.
|
|
@@ -237,7 +237,7 @@ module Cogworker
|
|
|
237
237
|
# each one's visible text to the *browser's local* time, in
|
|
238
238
|
# `Web.time_format`. Registered on `htmx:load`, which htmx fires once
|
|
239
239
|
# for the initial page **and** again after every fragment swap — so
|
|
240
|
-
# newly-swapped-in timestamps (an auto-refreshed
|
|
240
|
+
# newly-swapped-in timestamps (an auto-refreshed Workers/Jobs/Schedules
|
|
241
241
|
# tab) get the same treatment without a full reload.
|
|
242
242
|
#
|
|
243
243
|
# `window.cogworkerFormatTime` is the same formatter exposed globally
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rack/session/cookie'
|
|
4
|
+
|
|
5
|
+
module Cogworker
|
|
6
|
+
class Web
|
|
7
|
+
# The Web UI's own session cookie, applied only when nothing upstream
|
|
8
|
+
# already provides `env['rack.session']`. Mounted inside an app that has
|
|
9
|
+
# its own session (Rails, or any `Rack::Session::*` in front), an
|
|
10
|
+
# unconditional `Rack::Session::Cookie` here replaced that session with
|
|
11
|
+
# a copy for everything inside the Web UI — so an auth middleware added
|
|
12
|
+
# via `Web.use` read and wrote the copy, never the host's real session,
|
|
13
|
+
# and every response carried a second `cogworker.session` cookie.
|
|
14
|
+
class OptionalSession
|
|
15
|
+
def initialize(app, **options)
|
|
16
|
+
@app = app
|
|
17
|
+
@with_session = Rack::Session::Cookie.new(app, **options)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def call(env)
|
|
21
|
+
env['rack.session'] ? @app.call(env) : @with_session.call(env)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|