cogworker 0.3.0 → 0.4.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/lib/cogworker/attempts.rb +1 -1
- data/lib/cogworker/basic_fetch.rb +46 -3
- data/lib/cogworker/config.rb +29 -1
- data/lib/cogworker/heartbeat.rb +38 -4
- data/lib/cogworker/history/storage.rb +2 -2
- data/lib/cogworker/job_util.rb +50 -23
- data/lib/cogworker/launcher.rb +26 -2
- data/lib/cogworker/logging.rb +4 -0
- data/lib/cogworker/lua_script.rb +30 -0
- data/lib/cogworker/manager.rb +27 -4
- data/lib/cogworker/owned_key.rb +2 -2
- data/lib/cogworker/periodic/claim.lua +7 -4
- data/lib/cogworker/periodic/release_middleware.rb +2 -2
- data/lib/cogworker/periodic/running_lock.rb +29 -4
- data/lib/cogworker/periodic/ticker.rb +65 -11
- data/lib/cogworker/processor.rb +161 -49
- data/lib/cogworker/redis_keys.rb +7 -0
- data/lib/cogworker/reliable_fetch/unsupported_error.rb +11 -0
- data/lib/cogworker/reliable_fetch.rb +222 -43
- data/lib/cogworker/scheduled.rb +38 -7
- data/lib/cogworker/status/server_middleware.rb +1 -1
- data/lib/cogworker/swarm.rb +1 -0
- data/lib/cogworker/version.rb +1 -1
- data/lib/cogworker/web/optional_session.rb +32 -0
- data/lib/cogworker/web/routes/jobs.rb +58 -23
- data/lib/cogworker/web.rb +16 -2
- data/lib/cogworker.rb +11 -0
- metadata +4 -1
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'digest/sha1'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
3
6
|
module Cogworker
|
|
4
7
|
# The default fetch (`config.fetch = :reliable`): every job is moved
|
|
5
8
|
# atomically (`LMOVE`, Redis >= 6.2) from its queue onto this process's own
|
|
@@ -8,8 +11,8 @@ module Cogworker
|
|
|
8
11
|
# retry/dead. A job is therefore never only in a processor's memory: if the
|
|
9
12
|
# process dies mid-job (OOM, SIGKILL, a host going away), the job is still
|
|
10
13
|
# 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
|
|
12
|
-
#
|
|
14
|
+
# `Scheduled` poller) puts it back on its queue once the dead process has
|
|
15
|
+
# gone `config.orphan_threshold` (default 5 min) without a heartbeat. The trade-off is at-least-once delivery: a job
|
|
13
16
|
# that was partly done when its process died runs again from the start.
|
|
14
17
|
#
|
|
15
18
|
# `LMOVE` can only take from one list at a time, so there's no blocking
|
|
@@ -19,7 +22,12 @@ module Cogworker
|
|
|
19
22
|
# round sleeps EMPTY_POLL_INTERVAL before the next. Weighting and
|
|
20
23
|
# `Queue#pause!` behave exactly as in BasicFetch.
|
|
21
24
|
class ReliableFetch < BasicFetch
|
|
22
|
-
|
|
25
|
+
# The first pause after an empty round; each further empty round in a
|
|
26
|
+
# row doubles it, up to `config.fetch_idle_max_interval` (default 1s),
|
|
27
|
+
# and finding a job resets it. That cap is the worst-case pickup delay
|
|
28
|
+
# on an idle process — the trade-off against how often idle processors
|
|
29
|
+
# poll Redis (at 0.25s flat, 4 calls/s per thread).
|
|
30
|
+
EMPTY_POLL_INTERVAL = 0.25
|
|
23
31
|
MIN_REDIS_VERSION = Gem::Version.new('6.2')
|
|
24
32
|
|
|
25
33
|
# KEYS[1..n-1] = queue keys, in the order to try; KEYS[n] = in-progress list.
|
|
@@ -34,69 +42,199 @@ module Cogworker
|
|
|
34
42
|
return false
|
|
35
43
|
LUA
|
|
36
44
|
|
|
37
|
-
#
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
#
|
|
43
|
-
|
|
45
|
+
# (Run by hash, like every script here — see LuaScript.)
|
|
46
|
+
FETCH_SCRIPT_SHA = LuaScript.sha(FETCH_SCRIPT)
|
|
47
|
+
|
|
48
|
+
# Lua shared by every script here that puts a job back on its queue:
|
|
49
|
+
# onto the end its queue is popped from (so it runs next), onto
|
|
50
|
+
# `default_queue` if its own `queue` can't be read (the Processor then
|
|
51
|
+
# buries it as unparseable) — and a job from an `until_executed`
|
|
52
|
+
# periodic entry re-takes its running lock on the way
|
|
53
|
+
# (Periodic::RunningLock::RETAKE_FUNCTION), so the ticker can't start
|
|
54
|
+
# the next slot alongside the requeued run.
|
|
55
|
+
REQUEUE_JOB_FUNCTION = Periodic::RunningLock::RETAKE_FUNCTION + <<~LUA
|
|
56
|
+
local function requeue_job(job, queue_prefix, default_queue, lock_prefix, lock_ttl)
|
|
57
|
+
local queue = default_queue
|
|
58
|
+
local ok, decoded = pcall(cjson.decode, job)
|
|
59
|
+
if ok and type(decoded) == "table" then
|
|
60
|
+
if type(decoded["queue"]) == "string" and decoded["queue"] ~= "" then
|
|
61
|
+
queue = decoded["queue"]
|
|
62
|
+
end
|
|
63
|
+
local pjid, jid = decoded["periodic_pjid"], decoded["jid"]
|
|
64
|
+
if decoded["periodic_until_executed"] == true and type(pjid) == "string" and type(jid) == "string" then
|
|
65
|
+
retake_running_lock(lock_prefix .. pjid, jid, lock_ttl)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
redis.call("RPUSH", queue_prefix .. queue, job)
|
|
69
|
+
end
|
|
70
|
+
LUA
|
|
71
|
+
|
|
72
|
+
# KEYS[1] = an in-progress list, KEYS[2] = its owner's presence key,
|
|
73
|
+
# KEYS[3] = LAST_BEAT; ARGV[1..4] = requeue_job's prefix/default
|
|
74
|
+
# queue/lock prefix/lock TTL, ARGV[5] = owner identity, ARGV[6] = orphan
|
|
75
|
+
# threshold, or "" to requeue unconditionally (this process's own list,
|
|
76
|
+
# on shutdown). Returns how many it moved, or -1 if the owner turned
|
|
77
|
+
# out to be alive. Oldest first, so recovered jobs keep their order. The
|
|
78
|
+
# "is the owner really dead?" check (see ReliableFetch.dead?) is made
|
|
79
|
+
# here, atomically with the requeue: checked separately, an owner that
|
|
80
|
+
# beat in between would lose jobs it is still running to a second run.
|
|
81
|
+
# Atomic per call, so two processes recovering one list can't both move
|
|
82
|
+
# a job.
|
|
83
|
+
REQUEUE_SCRIPT = REQUEUE_JOB_FUNCTION + <<~LUA
|
|
84
|
+
if ARGV[6] ~= "" then
|
|
85
|
+
if redis.call("EXISTS", KEYS[2]) == 1 then
|
|
86
|
+
return -1
|
|
87
|
+
end
|
|
88
|
+
local last = redis.call("HGET", KEYS[3], ARGV[5])
|
|
89
|
+
if last then
|
|
90
|
+
if redis.replicate_commands then redis.replicate_commands() end
|
|
91
|
+
local now = tonumber(redis.call("TIME")[1])
|
|
92
|
+
if now - tonumber(last) <= tonumber(ARGV[6]) then
|
|
93
|
+
return -1
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
44
97
|
local moved = 0
|
|
45
98
|
while true do
|
|
46
99
|
local job = redis.call("LPOP", KEYS[1])
|
|
47
100
|
if not job then
|
|
48
101
|
return moved
|
|
49
102
|
end
|
|
50
|
-
|
|
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)
|
|
103
|
+
requeue_job(job, ARGV[1], ARGV[2], ARGV[3], ARGV[4])
|
|
56
104
|
moved = moved + 1
|
|
57
105
|
end
|
|
58
106
|
LUA
|
|
59
107
|
|
|
60
|
-
# KEYS[1] = in-progress list
|
|
61
|
-
|
|
108
|
+
# KEYS[1] = an in-progress list; ARGV[1] = one job on it, ARGV[2..5] =
|
|
109
|
+
# requeue_job's arguments. That one job, if still on the list, back on
|
|
110
|
+
# its queue: `give_back`, and `reconcile`'s strays.
|
|
111
|
+
REQUEUE_ONE_SCRIPT = REQUEUE_JOB_FUNCTION + <<~LUA
|
|
62
112
|
if redis.call("LREM", KEYS[1], 1, ARGV[1]) == 1 then
|
|
63
|
-
|
|
113
|
+
requeue_job(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5])
|
|
114
|
+
return 1
|
|
64
115
|
end
|
|
116
|
+
return 0
|
|
117
|
+
LUA
|
|
118
|
+
|
|
119
|
+
# KEYS[1] = in-progress list, KEYS[2] = retry/dead ZSET; ARGV[1] = the
|
|
120
|
+
# job as fetched, ARGV[2] = score, ARGV[3] = payload to file. Off the
|
|
121
|
+
# in-progress list and into the ZSET in one step; a no-op if it's no
|
|
122
|
+
# longer in progress (already requeued by a shutdown).
|
|
123
|
+
INTERRUPT_SCRIPT = <<~LUA
|
|
124
|
+
if redis.call("LREM", KEYS[1], 1, ARGV[1]) == 1 then
|
|
125
|
+
redis.call("ZADD", KEYS[2], ARGV[2], ARGV[3])
|
|
126
|
+
return 1
|
|
127
|
+
end
|
|
128
|
+
return 0
|
|
65
129
|
LUA
|
|
66
130
|
|
|
67
131
|
class << self
|
|
132
|
+
# A connection error propagates: can't tell yet, so the caller
|
|
133
|
+
# (Manager#fetch_class) resolves again on its next use, instead of
|
|
134
|
+
# memoizing a guess made while Redis was unreachable. Anything else
|
|
135
|
+
# (INFO restricted, an odd version string) assumes a current server —
|
|
136
|
+
# the runtime fall-back (UnsupportedError) still catches a wrong guess.
|
|
68
137
|
def supported?
|
|
69
138
|
version = Cogworker.config.redis { |c| c.info('server')['redis_version'] }
|
|
70
139
|
Gem::Version.new(version) >= MIN_REDIS_VERSION
|
|
140
|
+
rescue Redis::BaseConnectionError
|
|
141
|
+
raise
|
|
71
142
|
rescue StandardError
|
|
72
|
-
true
|
|
143
|
+
true
|
|
73
144
|
end
|
|
74
145
|
|
|
75
146
|
# Puts every job still on `identity`'s in-progress list back on its
|
|
76
|
-
# queue; returns how many
|
|
77
|
-
#
|
|
78
|
-
|
|
147
|
+
# queue; returns how many — or -1 if `if_dead_for:` is given and the
|
|
148
|
+
# owner turns out not to be dead (see REQUEUE_SCRIPT). Used without
|
|
149
|
+
# it for this process's own unfinished jobs on shutdown
|
|
150
|
+
# (`Manager#stop!`), with it for presumed-dead processes' lists.
|
|
151
|
+
def requeue_in_progress(identity, if_dead_for: nil)
|
|
79
152
|
Cogworker.config.redis do |c|
|
|
80
|
-
|
|
153
|
+
LuaScript.run(c, REQUEUE_SCRIPT,
|
|
154
|
+
keys: [RedisKeys.in_progress(identity), RedisKeys.process(identity), RedisKeys::LAST_BEAT],
|
|
155
|
+
argv: [*requeue_args, identity, if_dead_for.to_s])
|
|
81
156
|
end
|
|
82
157
|
end
|
|
83
158
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
159
|
+
def requeue_args
|
|
160
|
+
[RedisKeys::QUEUE_PREFIX, 'default', RedisKeys.periodic_running(''), Periodic::RunningLock.queued_ttl]
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Requeues the in-progress list of every process that has died without
|
|
164
|
+
# a clean shutdown. "Dead" means its presence key is gone (no beat for
|
|
165
|
+
# Heartbeat::TTL) *and* `config.orphan_threshold` has passed since its
|
|
166
|
+
# last recorded beat (LAST_BEAT, on Redis's clock) — the presence key
|
|
167
|
+
# alone expires after a minute, too eagerly for a live process that
|
|
168
|
+
# merely couldn't beat for a while (a Redis outage or failover, a long
|
|
169
|
+
# GVL-holding call), whose running jobs would then run twice. With no
|
|
170
|
+
# recorded beat at all (lists from before LAST_BEAT existed), the
|
|
171
|
+
# presence key alone decides. Checked inside REQUEUE_SCRIPT, atomically
|
|
172
|
+
# with the requeue. A process always beats before its first fetch
|
|
173
|
+
# (`Heartbeat#start!`). Walks IN_PROGRESS_IDENTITIES (each
|
|
174
|
+
# reliable-fetch process registers itself there on every beat) rather
|
|
175
|
+
# than SCANning the keyspace; `scan: true` (once per reliable process,
|
|
176
|
+
# at boot) also picks up lists from before that set existed.
|
|
177
|
+
def recover_orphans(scan: false)
|
|
89
178
|
own = Cogworker.identity
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
identity = key.delete_prefix(RedisKeys::IN_PROGRESS_PREFIX)
|
|
93
|
-
next 0 if identity == own || Cogworker.config.redis { |c| c.exists?(RedisKeys.process(identity)) }
|
|
179
|
+
candidate_identities(scan: scan).sum do |identity|
|
|
180
|
+
next 0 if identity == own
|
|
94
181
|
|
|
95
|
-
requeue_in_progress(identity
|
|
96
|
-
|
|
182
|
+
moved = requeue_in_progress(identity, if_dead_for: Cogworker.config.orphan_threshold)
|
|
183
|
+
next 0 if moved.negative? # alive after all: keep its registration and last beat
|
|
97
184
|
|
|
98
|
-
|
|
185
|
+
forget_if_empty(identity)
|
|
186
|
+
Cogworker.logger.warn { "requeued #{moved} job(s) left in progress by dead process #{identity}" } if moved.positive?
|
|
187
|
+
moved
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# This process's own in-progress entries that no processor thread is
|
|
192
|
+
# running (not in `cogworker:workers:<identity>`) — left behind when
|
|
193
|
+
# neither acknowledging nor handing a job back worked. Requeued once
|
|
194
|
+
# they've been seen stray on two calls in a row (the caller passes
|
|
195
|
+
# back what the previous call returned), which rules out a job caught
|
|
196
|
+
# in the instant between being fetched and registered. Returns this
|
|
197
|
+
# call's strays that weren't requeued yet.
|
|
198
|
+
def reconcile(identity, previous_strays = [])
|
|
199
|
+
Cogworker.config.redis do |c|
|
|
200
|
+
listed = c.lrange(RedisKeys.in_progress(identity), 0, -1)
|
|
201
|
+
running = c.hvals(RedisKeys.workers(identity)).filter_map { |raw| parse_hash(raw)&.dig('payload', 'jid') }
|
|
202
|
+
strays = listed.reject { |raw| running.include?(parse_hash(raw)&.fetch('jid', nil)) }
|
|
203
|
+
(strays & previous_strays).each do |raw|
|
|
204
|
+
next unless LuaScript.run(c, REQUEUE_ONE_SCRIPT, keys: [RedisKeys.in_progress(identity)],
|
|
205
|
+
argv: [raw, *requeue_args]) == 1
|
|
206
|
+
|
|
207
|
+
Cogworker.logger.warn { "requeued stray in-progress job jid=#{parse_hash(raw)&.fetch('jid', nil)}" }
|
|
99
208
|
end
|
|
209
|
+
strays - previous_strays
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def parse_hash(raw)
|
|
214
|
+
value = JSON.parse(raw)
|
|
215
|
+
value.is_a?(Hash) ? value : nil
|
|
216
|
+
rescue JSON::ParserError
|
|
217
|
+
nil
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def candidate_identities(scan:)
|
|
221
|
+
Cogworker.config.redis do |c|
|
|
222
|
+
identities = c.smembers(RedisKeys::IN_PROGRESS_IDENTITIES)
|
|
223
|
+
next identities unless scan
|
|
224
|
+
|
|
225
|
+
scanned = c.scan_each(match: "#{RedisKeys::IN_PROGRESS_PREFIX}*").map do |key|
|
|
226
|
+
key.delete_prefix(RedisKeys::IN_PROGRESS_PREFIX)
|
|
227
|
+
end
|
|
228
|
+
identities | scanned
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def forget_if_empty(identity)
|
|
233
|
+
Cogworker.config.redis do |c|
|
|
234
|
+
next unless c.llen(RedisKeys.in_progress(identity)).zero?
|
|
235
|
+
|
|
236
|
+
c.srem?(RedisKeys::IN_PROGRESS_IDENTITIES, identity)
|
|
237
|
+
c.hdel(RedisKeys::LAST_BEAT, identity)
|
|
100
238
|
end
|
|
101
239
|
end
|
|
102
240
|
end
|
|
@@ -104,18 +242,20 @@ module Cogworker
|
|
|
104
242
|
def retrieve_work
|
|
105
243
|
keys = active_keys
|
|
106
244
|
if keys.empty?
|
|
107
|
-
|
|
245
|
+
interruptible_sleep(TIMEOUT)
|
|
108
246
|
return nil
|
|
109
247
|
end
|
|
110
248
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
249
|
+
distinct = keys.uniq
|
|
250
|
+
return blocking_fetch(distinct.first) if distinct.size == 1
|
|
251
|
+
|
|
252
|
+
result = run_fetch_script(keys.shuffle.uniq + [RedisKeys.in_progress(Cogworker.identity)])
|
|
114
253
|
unless result
|
|
115
|
-
|
|
254
|
+
idle_sleep
|
|
116
255
|
return nil
|
|
117
256
|
end
|
|
118
257
|
|
|
258
|
+
@idle_interval = nil
|
|
119
259
|
queue_key, raw_job = result
|
|
120
260
|
UnitOfWork.new(queue_key.delete_prefix(RedisKeys::QUEUE_PREFIX), raw_job)
|
|
121
261
|
end
|
|
@@ -125,13 +265,52 @@ module Cogworker
|
|
|
125
265
|
# no longer in progress (already requeued by `requeue_in_progress`).
|
|
126
266
|
def give_back(work)
|
|
127
267
|
Cogworker.config.redis do |c|
|
|
128
|
-
|
|
129
|
-
|
|
268
|
+
LuaScript.run(c, REQUEUE_ONE_SCRIPT, keys: [RedisKeys.in_progress(Cogworker.identity)],
|
|
269
|
+
argv: [work.raw_job, *self.class.requeue_args])
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def interrupt(work, set, score, payload)
|
|
274
|
+
Cogworker.config.redis do |c|
|
|
275
|
+
LuaScript.run(c, INTERRUPT_SCRIPT, keys: [RedisKeys.in_progress(Cogworker.identity), set],
|
|
276
|
+
argv: [work.raw_job, score, payload])
|
|
130
277
|
end
|
|
131
278
|
end
|
|
132
279
|
|
|
133
280
|
def acknowledge(work)
|
|
134
281
|
Cogworker.config.redis { |c| c.lrem(RedisKeys.in_progress(Cogworker.identity), 1, work.raw_job) }
|
|
135
282
|
end
|
|
283
|
+
|
|
284
|
+
private
|
|
285
|
+
|
|
286
|
+
# With a single (unpaused) queue there's no weighting to honor, so no
|
|
287
|
+
# need to poll: a blocking BLMOVE waits on the server and returns the
|
|
288
|
+
# moment a job arrives, for up to TIMEOUT — same as BasicFetch's BRPOP.
|
|
289
|
+
def blocking_fetch(queue_key)
|
|
290
|
+
raw_job = Cogworker.config.redis do |c|
|
|
291
|
+
c.blmove(queue_key, RedisKeys.in_progress(Cogworker.identity), 'RIGHT', 'LEFT', timeout: TIMEOUT)
|
|
292
|
+
end
|
|
293
|
+
raw_job && UnitOfWork.new(queue_key.delete_prefix(RedisKeys::QUEUE_PREFIX), raw_job)
|
|
294
|
+
rescue Redis::CommandError => e
|
|
295
|
+
raise unless e.message.match?(/unknown (redis )?command/i)
|
|
296
|
+
|
|
297
|
+
raise UnsupportedError, e.message
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def run_fetch_script(keys)
|
|
301
|
+
Cogworker.config.redis { |c| LuaScript.run(c, FETCH_SCRIPT, keys: keys) }
|
|
302
|
+
rescue Redis::CommandError => e
|
|
303
|
+
raise unless e.message.match?(/unknown (redis )?command/i)
|
|
304
|
+
|
|
305
|
+
raise UnsupportedError, e.message
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# Jittered (75–100% of the interval) so a process's threads, all idle
|
|
309
|
+
# at once, don't keep polling in lockstep.
|
|
310
|
+
def idle_sleep
|
|
311
|
+
max = Cogworker.config.fetch_idle_max_interval
|
|
312
|
+
@idle_interval = @idle_interval ? [@idle_interval * 2, max].min : [EMPTY_POLL_INTERVAL, max].min
|
|
313
|
+
interruptible_sleep(@idle_interval * (0.75 + (rand * 0.25)))
|
|
314
|
+
end
|
|
136
315
|
end
|
|
137
316
|
end
|
data/lib/cogworker/scheduled.rb
CHANGED
|
@@ -13,10 +13,28 @@ module Cogworker
|
|
|
13
13
|
POLL_INTERVAL = 5
|
|
14
14
|
# How often this poller also looks for jobs left in progress by a dead
|
|
15
15
|
# process (ReliableFetch.recover_orphans) — the first time right away,
|
|
16
|
-
# at boot. Every live process does it
|
|
17
|
-
#
|
|
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.
|
|
18
23
|
ORPHAN_CHECK_INTERVAL = 60
|
|
19
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
|
|
37
|
+
|
|
20
38
|
def initialize(manager)
|
|
21
39
|
@manager = manager
|
|
22
40
|
end
|
|
@@ -37,6 +55,12 @@ module Cogworker
|
|
|
37
55
|
@thread&.kill
|
|
38
56
|
end
|
|
39
57
|
|
|
58
|
+
# True once the first orphan check has completed — what the periodic
|
|
59
|
+
# Ticker waits for before its first tick (see Launcher#initialize).
|
|
60
|
+
def recovered_once?
|
|
61
|
+
@recovered_once == true
|
|
62
|
+
end
|
|
63
|
+
|
|
40
64
|
private
|
|
41
65
|
|
|
42
66
|
# A failed poll is logged and retried next interval rather than ending
|
|
@@ -54,12 +78,17 @@ module Cogworker
|
|
|
54
78
|
end
|
|
55
79
|
end
|
|
56
80
|
|
|
81
|
+
# Next check scheduled only once this one has completed: a failed one
|
|
82
|
+
# (Redis down) is retried on the next poll rather than a minute later.
|
|
57
83
|
def recover_orphans_if_due
|
|
58
84
|
now = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
59
85
|
return if @next_orphan_check && now < @next_orphan_check
|
|
60
86
|
|
|
87
|
+
reliable = @manager.fetch_class == ReliableFetch
|
|
88
|
+
ReliableFetch.recover_orphans(scan: !@recovered_once && reliable)
|
|
89
|
+
@strays = ReliableFetch.reconcile(Cogworker.identity, @strays || []) if reliable
|
|
90
|
+
@recovered_once = true
|
|
61
91
|
@next_orphan_check = now + ORPHAN_CHECK_INTERVAL
|
|
62
|
-
ReliableFetch.recover_orphans
|
|
63
92
|
end
|
|
64
93
|
|
|
65
94
|
def enqueue_due_jobs
|
|
@@ -78,16 +107,18 @@ module Cogworker
|
|
|
78
107
|
Cogworker.config.redis do |c|
|
|
79
108
|
next unless JobUtil.claim_and_requeue(c, set, raw) == :invalid
|
|
80
109
|
|
|
81
|
-
bury(c, set, raw)
|
|
110
|
+
bury(c, set, raw)
|
|
82
111
|
end
|
|
83
112
|
end
|
|
84
113
|
|
|
85
114
|
def bury(conn, set, raw)
|
|
86
115
|
error = JSON::ParserError.new("not a job (JSON object with a queue) in #{set}")
|
|
87
116
|
job = JobUtil.unparseable_job(raw, queue: nil, error: error)
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
117
|
+
buried = LuaScript.run(conn, BURY_SCRIPT, keys: [set, RedisKeys::DEAD, RedisKeys::STATS_FAILED],
|
|
118
|
+
argv: [raw, job['failed_at'], JSON.generate(job)])
|
|
119
|
+
return unless buried == 1
|
|
120
|
+
|
|
121
|
+
Throughput.record('failed') # counted like Processor#bury_unparseable
|
|
91
122
|
Cogworker.logger.error { "unparseable entry in #{set} moved to dead: #{raw.to_s[0, 200]}" }
|
|
92
123
|
end
|
|
93
124
|
end
|
|
@@ -15,7 +15,7 @@ module Cogworker
|
|
|
15
15
|
write(job, queue, 'complete')
|
|
16
16
|
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
17
17
|
write(job, queue, JobUtil.terminal_failure?(job) ? 'failed' : 'retrying',
|
|
18
|
-
error_class: e.class.name, error_message: e
|
|
18
|
+
error_class: e.class.name, error_message: JobUtil.error_message(e))
|
|
19
19
|
raise e
|
|
20
20
|
end
|
|
21
21
|
|
data/lib/cogworker/swarm.rb
CHANGED
|
@@ -69,6 +69,7 @@ module Cogworker
|
|
|
69
69
|
# indefinitely, so this OS process would never actually go away —
|
|
70
70
|
# and `Swarm#initiate_restart`'s own `Process.waitpid(pid)` for this
|
|
71
71
|
# exact child would then block forever right along with it.
|
|
72
|
+
Cogworker.flush_output!
|
|
72
73
|
::Process.exit!(true)
|
|
73
74
|
end
|
|
74
75
|
@children[pid] = slot
|
data/lib/cogworker/version.rb
CHANGED
|
@@ -0,0 +1,32 @@
|
|
|
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
|
+
|
|
24
|
+
# A session middleware added via `Web.use` sits *after* this one in
|
|
25
|
+
# the stack, so it can't be detected per request the way an upstream
|
|
26
|
+
# one is — `Web.build_app` checks for one up front instead.
|
|
27
|
+
def self.session_middleware?(middleware)
|
|
28
|
+
middleware.is_a?(Class) && middleware <= Rack::Session::Abstract::Persisted
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'cgi'
|
|
4
|
+
require 'digest/sha1'
|
|
4
5
|
require 'json'
|
|
5
6
|
|
|
6
7
|
module Cogworker
|
|
@@ -63,33 +64,30 @@ module Cogworker
|
|
|
63
64
|
app.post('/jobs/retrying/delete') do
|
|
64
65
|
raw = params['raw']
|
|
65
66
|
Cogworker.config.redis { |c| c.zrem(RedisKeys::RETRY, raw) }
|
|
66
|
-
Cogworker::Attempts.clear(
|
|
67
|
+
Cogworker::Attempts.clear(Jobs.parse_entry(raw)['jid'])
|
|
67
68
|
Jobs.respond(self, params)
|
|
68
69
|
end
|
|
69
70
|
|
|
70
71
|
# Same "graduate back onto its queue" move `/jobs/dead/retry` below/
|
|
71
|
-
# `Cogworker::Scheduled#graduate` make
|
|
72
|
-
#
|
|
73
|
-
# can't both requeue it
|
|
74
|
-
#
|
|
75
|
-
# `== 1` (`true == 1` is `false` in Ruby).
|
|
72
|
+
# `Cogworker::Scheduled#graduate` make (`JobUtil.claim_and_requeue`:
|
|
73
|
+
# validate, then claim + push atomically) — two tabs retrying the
|
|
74
|
+
# same entry at once can't both requeue it, and an entry that
|
|
75
|
+
# can't be requeued is left in place with a notice.
|
|
76
76
|
app.post('/jobs/retrying/retry_now') do
|
|
77
77
|
raw = params['raw']
|
|
78
|
-
Cogworker.config.redis
|
|
79
|
-
|
|
80
|
-
end
|
|
81
|
-
Jobs.respond(self, params)
|
|
78
|
+
result = Cogworker.config.redis { |c| JobUtil.claim_and_requeue(c, RedisKeys::RETRY, raw) }
|
|
79
|
+
Jobs.respond(self, params, notice: result == :invalid ? 'not_requeueable' : nil)
|
|
82
80
|
end
|
|
83
81
|
|
|
84
82
|
app.post('/jobs/dead/delete') do
|
|
85
83
|
raw = params['raw']
|
|
86
84
|
Cogworker.config.redis { |c| c.zrem(RedisKeys::DEAD, raw) }
|
|
87
|
-
Cogworker::Attempts.clear(
|
|
85
|
+
Cogworker::Attempts.clear(Jobs.parse_entry(raw)['jid'])
|
|
88
86
|
Jobs.respond(self, params)
|
|
89
87
|
end
|
|
90
88
|
|
|
91
89
|
app.post('/jobs/dead/delete_all') do
|
|
92
|
-
jids = Cogworker.config.redis { |c| c.zrange(RedisKeys::DEAD, 0, -1) }.map { |raw|
|
|
90
|
+
jids = Cogworker.config.redis { |c| c.zrange(RedisKeys::DEAD, 0, -1) }.map { |raw| Jobs.parse_entry(raw)['jid'] }
|
|
93
91
|
Cogworker.config.redis { |c| c.del(RedisKeys::DEAD) }
|
|
94
92
|
jids.each { |jid| Cogworker::Attempts.clear(jid) }
|
|
95
93
|
Jobs.respond(self, params)
|
|
@@ -97,10 +95,8 @@ module Cogworker
|
|
|
97
95
|
|
|
98
96
|
app.post('/jobs/dead/retry') do
|
|
99
97
|
raw = params['raw']
|
|
100
|
-
Cogworker.config.redis
|
|
101
|
-
|
|
102
|
-
end
|
|
103
|
-
Jobs.respond(self, params)
|
|
98
|
+
result = Cogworker.config.redis { |c| JobUtil.claim_and_requeue(c, RedisKeys::DEAD, raw) }
|
|
99
|
+
Jobs.respond(self, params, notice: result == :invalid ? 'not_requeueable' : nil)
|
|
104
100
|
end
|
|
105
101
|
|
|
106
102
|
# Moves this one entry's score on its own ZSET — "run sooner" or
|
|
@@ -144,11 +140,16 @@ module Cogworker
|
|
|
144
140
|
# #jobs-content in place, keeping the same filter/search/selection;
|
|
145
141
|
# a plain form submission (no JS) falls back to a normal redirect
|
|
146
142
|
# to that same URL.
|
|
147
|
-
|
|
143
|
+
# `notice:` is a key into NOTICES (never free text — it's echoed into
|
|
144
|
+
# the page). It shows once: the redirect carries it as `?notice=`,
|
|
145
|
+
# but `query_string` (and so every poll URL) leaves it out.
|
|
146
|
+
def respond(action, params, notice: nil)
|
|
148
147
|
if action.hx_request?
|
|
149
|
-
render_content(action.request.script_name, params)
|
|
148
|
+
render_content(action.request.script_name, params.merge('notice' => notice))
|
|
150
149
|
else
|
|
151
|
-
|
|
150
|
+
location = "jobs#{query_string(params)}"
|
|
151
|
+
location += "#{location.include?('?') ? '&' : '?'}notice=#{CGI.escape(notice)}" if notice
|
|
152
|
+
action.redirect(Layout.path(action.request.script_name, location))
|
|
152
153
|
end
|
|
153
154
|
end
|
|
154
155
|
|
|
@@ -163,6 +164,19 @@ module Cogworker
|
|
|
163
164
|
parts.empty? ? '' : "?#{parts.join('&')}"
|
|
164
165
|
end
|
|
165
166
|
|
|
167
|
+
NOTICES = {
|
|
168
|
+
'not_requeueable' => "This entry can't be put back on a queue — it's unreadable or has no queue " \
|
|
169
|
+
'of its own. Delete it instead.'
|
|
170
|
+
}.freeze
|
|
171
|
+
|
|
172
|
+
def notice_banner(params)
|
|
173
|
+
text = NOTICES[params['notice'].to_s]
|
|
174
|
+
return '' unless text
|
|
175
|
+
|
|
176
|
+
style = 'padding: 10px 14px; border-left: 3px solid var(--color-danger);'
|
|
177
|
+
%(<div class="card" role="status" style="#{style}">#{Layout.h(text)}</div>)
|
|
178
|
+
end
|
|
179
|
+
|
|
166
180
|
def render_content(script_name, params)
|
|
167
181
|
status = STATUSES.include?(params['status']) ? params['status'] : 'All'
|
|
168
182
|
query = params['q'].to_s
|
|
@@ -175,6 +189,7 @@ module Cogworker
|
|
|
175
189
|
|
|
176
190
|
<<~HTML
|
|
177
191
|
<div style="display: flex; flex-direction: column; gap: 16px;">
|
|
192
|
+
#{notice_banner(params)}
|
|
178
193
|
#{page_header(script_name, status, query, filtered.size, rows.size)}
|
|
179
194
|
<div style="display: grid; grid-template-columns: minmax(0, 1fr) #{selected ? 'minmax(320px, 400px)' : '0px'}; gap: 16px; align-items: start;">
|
|
180
195
|
#{jobs_table(filtered, script_name, params)}
|
|
@@ -259,8 +274,10 @@ module Cogworker
|
|
|
259
274
|
# No Retry for an entry that can't go back on a queue (no queue of
|
|
260
275
|
# its own) or that would only fail straight back into Dead (an
|
|
261
276
|
# unparseable payload's wrapper) — Delete is all that makes sense.
|
|
277
|
+
# From the row's already-parsed fields — this runs for every row on
|
|
278
|
+
# every poll, so no re-parsing `raw` here.
|
|
262
279
|
def requeueable?(row)
|
|
263
|
-
row[:klass] != JobUtil::UNPARSEABLE_CLASS &&
|
|
280
|
+
row[:klass] != JobUtil::UNPARSEABLE_CLASS && row[:queue].is_a?(String) && !row[:queue].empty?
|
|
264
281
|
end
|
|
265
282
|
|
|
266
283
|
def delete_button(bucket, script_name, row, params, extra: {})
|
|
@@ -430,10 +447,28 @@ module Cogworker
|
|
|
430
447
|
end
|
|
431
448
|
end
|
|
432
449
|
|
|
450
|
+
# A ZSET entry that isn't a job Hash (corrupt, or written by
|
|
451
|
+
# something else) shows up as an `(unparseable)` row — Delete is
|
|
452
|
+
# offered, Retry isn't (`requeueable?`) — instead of a JSON::ParserError
|
|
453
|
+
# taking the whole tab down.
|
|
454
|
+
def parse_entry(raw)
|
|
455
|
+
job = JSON.parse(raw)
|
|
456
|
+
return job if job.is_a?(Hash)
|
|
457
|
+
|
|
458
|
+
unparseable_entry(raw)
|
|
459
|
+
rescue JSON::ParserError
|
|
460
|
+
unparseable_entry(raw)
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
def unparseable_entry(raw)
|
|
464
|
+
{ 'class' => JobUtil::UNPARSEABLE_CLASS, 'jid' => "unparseable-#{Digest::SHA1.hexdigest(raw.to_s)[0, 12]}",
|
|
465
|
+
'args' => [], 'error_message' => raw.to_s[0, 200] }
|
|
466
|
+
end
|
|
467
|
+
|
|
433
468
|
def scheduled_rows
|
|
434
469
|
entries = Cogworker.config.redis { |c| c.zrange(RedisKeys::SCHEDULE, 0, -1, withscores: true) }
|
|
435
470
|
entries.map do |raw, score|
|
|
436
|
-
job =
|
|
471
|
+
job = parse_entry(raw)
|
|
437
472
|
{ jid: job['jid'], klass: job['class'], queue: job['queue'], status: 'Scheduled', attempt: '—',
|
|
438
473
|
next_action: score, at: job['created_at'], args: job['args'], error_class: nil, error_message: nil,
|
|
439
474
|
raw: raw, source: :scheduled }
|
|
@@ -443,7 +478,7 @@ module Cogworker
|
|
|
443
478
|
def retrying_rows
|
|
444
479
|
entries = Cogworker.config.redis { |c| c.zrange(RedisKeys::RETRY, 0, -1, withscores: true) }
|
|
445
480
|
entries.map do |raw, score|
|
|
446
|
-
job =
|
|
481
|
+
job = parse_entry(raw)
|
|
447
482
|
{ jid: job['jid'], klass: job['class'], queue: job['queue'], status: 'Retrying',
|
|
448
483
|
attempt: "#{job['retry_count'].to_i} of #{JobUtil.max_retries(job)}", next_action: score,
|
|
449
484
|
at: job['failed_at'] || job['created_at'], args: job['args'], error_class: job['error_class'],
|
|
@@ -454,7 +489,7 @@ module Cogworker
|
|
|
454
489
|
def dead_rows
|
|
455
490
|
entries = Cogworker.config.redis { |c| c.zrevrange(RedisKeys::DEAD, 0, -1, withscores: true) }
|
|
456
491
|
entries.map do |raw, score|
|
|
457
|
-
job =
|
|
492
|
+
job = parse_entry(raw)
|
|
458
493
|
{ jid: job['jid'], klass: job['class'], queue: job['queue'], status: 'Dead',
|
|
459
494
|
attempt: "#{job['retry_count'].to_i} of #{JobUtil.max_retries(job)}", next_action: nil, at: score,
|
|
460
495
|
args: job['args'], error_class: job['error_class'], error_message: job['error_message'], raw: raw,
|