cogworker 0.3.0 → 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 +24 -1
- data/lib/cogworker/config.rb +29 -1
- data/lib/cogworker/heartbeat.rb +28 -4
- data/lib/cogworker/job_util.rb +27 -22
- data/lib/cogworker/launcher.rb +19 -1
- data/lib/cogworker/logging.rb +4 -0
- data/lib/cogworker/manager.rb +27 -4
- 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 +44 -8
- data/lib/cogworker/processor.rb +33 -5
- data/lib/cogworker/redis_keys.rb +7 -0
- data/lib/cogworker/reliable_fetch/unsupported_error.rb +11 -0
- data/lib/cogworker/reliable_fetch.rb +109 -21
- data/lib/cogworker/scheduled.rb +28 -7
- data/lib/cogworker/swarm.rb +1 -0
- data/lib/cogworker/version.rb +1 -1
- data/lib/cogworker/web/optional_session.rb +25 -0
- data/lib/cogworker/web/routes/jobs.rb +58 -23
- data/lib/cogworker/web.rb +15 -2
- data/lib/cogworker.rb +11 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bfb90fe3a477f31658c58fd09a41e4404a0c09999b74614789bd343ee704fad2
|
|
4
|
+
data.tar.gz: 407aa20293538fa35c8219f88f21fd8b1c15150d96e77d2b495d017ef21f924e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c7df3eba977499a45acca66e8d25fd87cfe4669b9098c1e99be798f587de2ddf76edc75adb01953e8fe311ac324d7e4eb44cce66a33be264efe4f266e67d8f3e
|
|
7
|
+
data.tar.gz: 7fa4d7ba18f7be8ae8e80bd7b260be8bd8e9387122a623802812abd166652859f48669bbb42c16f72f15df1104d855934f4cfdb6cf6aede2737d740540d27278
|
|
@@ -62,7 +62,7 @@ module Cogworker
|
|
|
62
62
|
Cogworker.config.redis do |c|
|
|
63
63
|
c.hvals(RedisKeys.workers(identity)).count do |raw|
|
|
64
64
|
entry = JSON.parse(raw)
|
|
65
|
-
c
|
|
65
|
+
requeue_entry(c, entry.fetch('queue'), entry.fetch('payload'))
|
|
66
66
|
true
|
|
67
67
|
rescue StandardError => e
|
|
68
68
|
Cogworker.logger.error { "couldn't requeue in-flight entry #{raw.to_s[0, 200]}: #{e.class}: #{e.message}" }
|
|
@@ -71,6 +71,16 @@ module Cogworker
|
|
|
71
71
|
end
|
|
72
72
|
end
|
|
73
73
|
|
|
74
|
+
def self.requeue_entry(conn, queue, job)
|
|
75
|
+
queue_key = RedisKeys.queue(queue)
|
|
76
|
+
retake = job['periodic_until_executed'] && job['periodic_pjid'] && job['jid']
|
|
77
|
+
lock_key = retake ? RedisKeys.periodic_running(job['periodic_pjid']) : queue_key
|
|
78
|
+
conn.eval(REQUEUE_ENTRY_SCRIPT, keys: [queue_key, lock_key],
|
|
79
|
+
argv: [JSON.generate(job), retake ? '1' : '0', job['jid'].to_s,
|
|
80
|
+
Periodic::RunningLock.queued_ttl])
|
|
81
|
+
end
|
|
82
|
+
private_class_method :requeue_entry
|
|
83
|
+
|
|
74
84
|
private
|
|
75
85
|
|
|
76
86
|
def active_keys
|
|
@@ -81,5 +91,18 @@ module Cogworker
|
|
|
81
91
|
end
|
|
82
92
|
|
|
83
93
|
UnitOfWork = Struct.new(:queue, :raw_job)
|
|
94
|
+
|
|
95
|
+
# KEYS[1] = the queue, KEYS[2] = the job's periodic running lock (or the
|
|
96
|
+
# queue again when there's none to take); ARGV[1] = payload, ARGV[2] =
|
|
97
|
+
# "1" to re-take that lock, ARGV[3] = jid, ARGV[4] = lock TTL. Requeue
|
|
98
|
+
# and lock in one step: as two commands, a failure in between left the
|
|
99
|
+
# job queued without its lock (and miscounted).
|
|
100
|
+
REQUEUE_ENTRY_SCRIPT = Periodic::RunningLock::RETAKE_FUNCTION + <<~LUA
|
|
101
|
+
redis.call("RPUSH", KEYS[1], ARGV[1])
|
|
102
|
+
if ARGV[2] == "1" then
|
|
103
|
+
retake_running_lock(KEYS[2], ARGV[3], ARGV[4])
|
|
104
|
+
end
|
|
105
|
+
return 1
|
|
106
|
+
LUA
|
|
84
107
|
end
|
|
85
108
|
end
|
data/lib/cogworker/config.rb
CHANGED
|
@@ -8,7 +8,8 @@ module Cogworker
|
|
|
8
8
|
class Config
|
|
9
9
|
FETCH_MODES = %i[reliable basic].freeze
|
|
10
10
|
|
|
11
|
-
attr_reader :server_chain, :client_chain, :periodic_manager, :redis_options, :fetch
|
|
11
|
+
attr_reader :server_chain, :client_chain, :periodic_manager, :redis_options, :fetch, :fetch_idle_max_interval,
|
|
12
|
+
:orphan_threshold
|
|
12
13
|
attr_accessor :concurrency, :queues, :periodic_catch_up, :unique_lock_ttl
|
|
13
14
|
|
|
14
15
|
def initialize
|
|
@@ -39,9 +40,36 @@ module Cogworker
|
|
|
39
40
|
# On an older Redis, `:reliable` falls back to `:basic` with a warning
|
|
40
41
|
# (Manager#resolve_fetch_class).
|
|
41
42
|
@fetch = :reliable
|
|
43
|
+
# Longest pause (seconds) ReliableFetch makes between polls of empty
|
|
44
|
+
# queues — the worst-case delay before an idle worker notices a new
|
|
45
|
+
# job. See ReliableFetch::EMPTY_POLL_INTERVAL.
|
|
46
|
+
@fetch_idle_max_interval = 1.0
|
|
47
|
+
# How long (seconds) a process must have gone without a heartbeat
|
|
48
|
+
# before ReliableFetch.recover_orphans treats it as dead and requeues
|
|
49
|
+
# its in-progress jobs. Higher: fewer false "dead" verdicts on a live
|
|
50
|
+
# process that merely couldn't beat for a while (a Redis outage or
|
|
51
|
+
# failover, a long GVL-holding call) — each of which runs its jobs
|
|
52
|
+
# twice; lower: a really crashed process's jobs come back sooner.
|
|
53
|
+
@orphan_threshold = 300
|
|
42
54
|
register_default_middleware
|
|
43
55
|
end
|
|
44
56
|
|
|
57
|
+
def orphan_threshold=(seconds)
|
|
58
|
+
unless seconds.is_a?(Numeric) && seconds.positive?
|
|
59
|
+
raise ArgumentError, "orphan_threshold must be a positive number of seconds, got #{seconds.inspect}"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
@orphan_threshold = seconds
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def fetch_idle_max_interval=(seconds)
|
|
66
|
+
unless seconds.is_a?(Numeric) && seconds.positive?
|
|
67
|
+
raise ArgumentError, "fetch_idle_max_interval must be a positive number of seconds, got #{seconds.inspect}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
@fetch_idle_max_interval = seconds
|
|
71
|
+
end
|
|
72
|
+
|
|
45
73
|
def fetch=(mode)
|
|
46
74
|
resolved = mode.to_s.to_sym
|
|
47
75
|
unless FETCH_MODES.include?(resolved)
|
data/lib/cogworker/heartbeat.rb
CHANGED
|
@@ -15,6 +15,7 @@ module Cogworker
|
|
|
15
15
|
class Heartbeat
|
|
16
16
|
INTERVAL = 5
|
|
17
17
|
TTL = 60
|
|
18
|
+
STARTUP_RETRY_INTERVAL = 1
|
|
18
19
|
|
|
19
20
|
def initialize(manager)
|
|
20
21
|
@manager = manager
|
|
@@ -23,12 +24,21 @@ module Cogworker
|
|
|
23
24
|
# The first beat is synchronous, before any processor fetches a job:
|
|
24
25
|
# ReliableFetch.recover_orphans treats an in-progress list with no live
|
|
25
26
|
# heartbeat behind it as a dead process's, so this process must be
|
|
26
|
-
# visibly alive before its own list can have anything on it.
|
|
27
|
-
|
|
27
|
+
# visibly alive before its own list can have anything on it. With Redis
|
|
28
|
+
# unreachable, it keeps retrying (every STARTUP_RETRY_INTERVAL) rather
|
|
29
|
+
# than letting the Manager start fetching with no presence key — until
|
|
30
|
+
# it succeeds (returns true) or `abort_if` says to give up (returns
|
|
31
|
+
# false, e.g. a stop signal arrived meanwhile; nothing is started then).
|
|
32
|
+
def start!(abort_if: -> { false })
|
|
28
33
|
@stopping = false
|
|
29
|
-
beat_safely
|
|
34
|
+
until beat_safely
|
|
35
|
+
return false if abort_if.call
|
|
36
|
+
|
|
37
|
+
sleep(STARTUP_RETRY_INTERVAL)
|
|
38
|
+
end
|
|
30
39
|
@beat_thread = Thread.new { beat_loop }
|
|
31
40
|
@signal_thread = Thread.new { subscribe_loop }
|
|
41
|
+
true
|
|
32
42
|
end
|
|
33
43
|
|
|
34
44
|
# Tears down both background threads, not just the Redis keys — leaving
|
|
@@ -67,6 +77,12 @@ module Cogworker
|
|
|
67
77
|
Cogworker.config.redis do |c|
|
|
68
78
|
c.del(RedisKeys.process(Cogworker.identity), RedisKeys.workers(Cogworker.identity))
|
|
69
79
|
c.srem?(RedisKeys::PROCESSES, Cogworker.identity)
|
|
80
|
+
# Clean shutdown: Manager#stop! already requeued this process's
|
|
81
|
+
# in-progress list, so there's nothing left for recovery to find.
|
|
82
|
+
if c.llen(RedisKeys.in_progress(Cogworker.identity)).zero?
|
|
83
|
+
c.srem?(RedisKeys::IN_PROGRESS_IDENTITIES, Cogworker.identity)
|
|
84
|
+
c.hdel(RedisKeys::LAST_BEAT, Cogworker.identity)
|
|
85
|
+
end
|
|
70
86
|
end
|
|
71
87
|
end
|
|
72
88
|
|
|
@@ -83,12 +99,15 @@ module Cogworker
|
|
|
83
99
|
|
|
84
100
|
def beat_safely
|
|
85
101
|
beat
|
|
102
|
+
true
|
|
86
103
|
rescue StandardError => e
|
|
87
104
|
Cogworker.logger.error { "Heartbeat failed: #{e.class}: #{e.message}" }
|
|
105
|
+
false
|
|
88
106
|
end
|
|
89
107
|
|
|
90
108
|
def beat
|
|
91
109
|
identity = Cogworker.identity
|
|
110
|
+
reliable = @manager.fetch_class == ReliableFetch
|
|
92
111
|
info = {
|
|
93
112
|
'hostname' => Cogworker.hostname,
|
|
94
113
|
'pid' => ::Process.pid,
|
|
@@ -106,6 +125,10 @@ module Cogworker
|
|
|
106
125
|
'quiet', @manager.quiet?.to_s)
|
|
107
126
|
c.expire(RedisKeys.process(identity), TTL)
|
|
108
127
|
c.expire(RedisKeys.workers(identity), TTL)
|
|
128
|
+
if reliable
|
|
129
|
+
c.sadd?(RedisKeys::IN_PROGRESS_IDENTITIES, identity)
|
|
130
|
+
c.hset(RedisKeys::LAST_BEAT, identity, c.time.first) # Redis's clock, not this host's
|
|
131
|
+
end
|
|
109
132
|
touch_running_periodic_locks(c, identity)
|
|
110
133
|
end
|
|
111
134
|
end
|
|
@@ -120,7 +143,7 @@ module Cogworker
|
|
|
120
143
|
job = JSON.parse(raw)['payload']
|
|
121
144
|
next unless job.is_a?(Hash) && job['periodic_pjid']
|
|
122
145
|
|
|
123
|
-
Periodic::RunningLock.touch(job['periodic_pjid'], job['jid'], Periodic::RunningLock
|
|
146
|
+
Periodic::RunningLock.touch(job['periodic_pjid'], job['jid'], Periodic::RunningLock.active_ttl, conn)
|
|
124
147
|
rescue StandardError => e
|
|
125
148
|
# Per entry: one bad entry mustn't leave the others' locks to expire.
|
|
126
149
|
Cogworker.logger.error { "couldn't refresh periodic lock for #{raw.to_s[0, 200]}: #{e.class}: #{e.message}" }
|
|
@@ -194,6 +217,7 @@ module Cogworker
|
|
|
194
217
|
def remote_stop!
|
|
195
218
|
@manager.stop!
|
|
196
219
|
cleanup_presence!
|
|
220
|
+
Cogworker.flush_output!
|
|
197
221
|
::Process.exit!(true)
|
|
198
222
|
end
|
|
199
223
|
end
|
data/lib/cogworker/job_util.rb
CHANGED
|
@@ -64,37 +64,42 @@ module Cogworker
|
|
|
64
64
|
# `queue` may be nil).
|
|
65
65
|
def requeueable(raw)
|
|
66
66
|
job = JSON.parse(raw)
|
|
67
|
-
job.is_a?(Hash) && job['queue'].is_a?(String) ? job : nil
|
|
67
|
+
job.is_a?(Hash) && job['queue'].is_a?(String) && !job['queue'].empty? ? job : nil
|
|
68
68
|
rescue JSON::ParserError, TypeError
|
|
69
69
|
nil
|
|
70
70
|
end
|
|
71
71
|
|
|
72
|
-
#
|
|
73
|
-
#
|
|
74
|
-
#
|
|
75
|
-
#
|
|
76
|
-
#
|
|
72
|
+
# KEYS[1] = the ZSET, KEYS[2] = cogworker:queues, KEYS[3] = the queue;
|
|
73
|
+
# ARGV[1] = the entry as stored, ARGV[2] = queue name, ARGV[3] = the
|
|
74
|
+
# payload to push. Claim and push in one atomic step: as separate
|
|
75
|
+
# commands, a crash or dropped connection between the ZREM and the
|
|
76
|
+
# LPUSH lost the job. The new payload is built in Ruby, not re-encoded
|
|
77
|
+
# by Lua's cjson (which would round large integers in args).
|
|
78
|
+
CLAIM_AND_REQUEUE_SCRIPT = <<~LUA
|
|
79
|
+
if redis.call("ZREM", KEYS[1], ARGV[1]) == 0 then
|
|
80
|
+
return 0
|
|
81
|
+
end
|
|
82
|
+
redis.call("SADD", KEYS[2], ARGV[2])
|
|
83
|
+
redis.call("LPUSH", KEYS[3], ARGV[3])
|
|
84
|
+
return 1
|
|
85
|
+
LUA
|
|
86
|
+
|
|
87
|
+
# Validate, then (atomically) claim and requeue — an entry that can't be
|
|
88
|
+
# requeued is never removed and lost (it stays where it is for the
|
|
89
|
+
# caller to deal with). Returns `:requeued`, `:gone` (someone else
|
|
90
|
+
# claimed it first) or `:invalid`. The one implementation behind
|
|
91
|
+
# `Scheduled#graduate` and every Web UI retry action. Re-stamps
|
|
92
|
+
# `enqueued_at`: queue latency is measured from it, and a retried job
|
|
93
|
+
# still carried its original push time (a scheduled one, none at all).
|
|
77
94
|
def claim_and_requeue(conn, set, raw)
|
|
78
95
|
job = requeueable(raw)
|
|
79
96
|
return :invalid unless job
|
|
80
|
-
return :gone unless conn.zrem(set, raw)
|
|
81
97
|
|
|
82
|
-
requeue(conn, job)
|
|
83
|
-
:requeued
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
# Puts a job taken off `cogworker:schedule`/`retry`/`dead` (the caller
|
|
87
|
-
# has already won its `zrem`) back onto its queue — the one shared
|
|
88
|
-
# implementation behind `Scheduled#graduate` and the Web UI's retry
|
|
89
|
-
# actions. Re-stamps `enqueued_at`: queue latency is measured from it,
|
|
90
|
-
# and a retried job still carried its original push time (a scheduled
|
|
91
|
-
# one, none at all), inflating the queue's latency by its whole history.
|
|
92
|
-
def requeue(conn, job)
|
|
93
98
|
job['enqueued_at'] = Time.now.to_f
|
|
94
|
-
conn.
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
99
|
+
won = conn.eval(CLAIM_AND_REQUEUE_SCRIPT,
|
|
100
|
+
keys: [set, RedisKeys::QUEUES, RedisKeys.queue(job['queue'])],
|
|
101
|
+
argv: [raw, job['queue'], JSON.generate(job)])
|
|
102
|
+
won == 1 ? :requeued : :gone
|
|
98
103
|
end
|
|
99
104
|
|
|
100
105
|
UNPARSEABLE_CLASS = '(unparseable)'
|
data/lib/cogworker/launcher.rb
CHANGED
|
@@ -23,7 +23,8 @@ module Cogworker
|
|
|
23
23
|
Cogworker.reset_identity!
|
|
24
24
|
install_signal_traps
|
|
25
25
|
resolve_periodic_classes
|
|
26
|
-
|
|
26
|
+
return unless start_heartbeat
|
|
27
|
+
|
|
27
28
|
@manager.start!
|
|
28
29
|
@scheduled.start!
|
|
29
30
|
@ticker.start!
|
|
@@ -44,6 +45,23 @@ module Cogworker
|
|
|
44
45
|
|
|
45
46
|
private
|
|
46
47
|
|
|
48
|
+
# First, and blocking until it succeeds: see Heartbeat#start!. A stop
|
|
49
|
+
# signal arriving while Redis is still unreachable ends the wait (and
|
|
50
|
+
# the process) without ever starting anything; a quiet one is applied
|
|
51
|
+
# and the wait goes on.
|
|
52
|
+
def start_heartbeat
|
|
53
|
+
until @heartbeat.start!(abort_if: -> { !@signal_queue.empty? })
|
|
54
|
+
case @signal_queue.pop
|
|
55
|
+
when :stop
|
|
56
|
+
Cogworker.logger.info { 'Received stop signal before startup completed' }
|
|
57
|
+
stop!
|
|
58
|
+
return false
|
|
59
|
+
when :quiet then quiet!
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
true
|
|
63
|
+
end
|
|
64
|
+
|
|
47
65
|
# Resolves every periodic entry's class here, on the main thread, before
|
|
48
66
|
# any processor thread exists. `Processor#build_worker` otherwise does
|
|
49
67
|
# the first `const_get` lazily on whichever processor thread picks the
|
data/lib/cogworker/logging.rb
CHANGED
|
@@ -7,6 +7,10 @@ module Cogworker
|
|
|
7
7
|
# it), with a compact one-line-per-entry format including pid.
|
|
8
8
|
module Logging
|
|
9
9
|
def self.default_logger(io = $stdout)
|
|
10
|
+
# Unbuffered: processes here end via `Process.exit!` (swarm children,
|
|
11
|
+
# a remote stop), which skips Ruby's IO flush — with stdout a pipe
|
|
12
|
+
# (docker, systemd) the last lines logged before exit were lost.
|
|
13
|
+
io.sync = true if io.respond_to?(:sync=)
|
|
10
14
|
logger = ::Logger.new(io)
|
|
11
15
|
logger.level = ::Logger::INFO
|
|
12
16
|
logger.formatter = proc do |severity, time, _progname, msg|
|
data/lib/cogworker/manager.rb
CHANGED
|
@@ -4,11 +4,9 @@ module Cogworker
|
|
|
4
4
|
# Owns the `concurrency`-sized thread pool of Processors for one OS
|
|
5
5
|
# process, and the quiet/stop state they all poll.
|
|
6
6
|
class Manager
|
|
7
|
-
attr_reader :fetch_class
|
|
8
|
-
|
|
9
7
|
def initialize(config = Cogworker.config)
|
|
10
8
|
@config = config
|
|
11
|
-
@
|
|
9
|
+
@fetch_mutex = Mutex.new
|
|
12
10
|
@processors = Array.new(config.concurrency) { Processor.new(self) }
|
|
13
11
|
@quiet = false
|
|
14
12
|
@stopping = false
|
|
@@ -20,6 +18,31 @@ module Cogworker
|
|
|
20
18
|
@config.queues
|
|
21
19
|
end
|
|
22
20
|
|
|
21
|
+
# Resolved on first use — from a processor thread's first fetch, after
|
|
22
|
+
# the Heartbeat's first beat proved Redis reachable — not in the
|
|
23
|
+
# constructor: that needed Redis just to build a Manager, and when
|
|
24
|
+
# Redis was unreachable at that moment, the version check couldn't run
|
|
25
|
+
# at all (see ReliableFetch.supported?).
|
|
26
|
+
def fetch_class
|
|
27
|
+
@fetch_mutex.synchronize { @fetch_class ||= resolve_fetch_class }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The server turned out not to support ReliableFetch after all
|
|
31
|
+
# (ReliableFetch::UnsupportedError on a real fetch): every processor
|
|
32
|
+
# switches to BasicFetch from its next fetch on. Safe, since no job can
|
|
33
|
+
# be on the in-progress list — no LMOVE ever succeeded.
|
|
34
|
+
def fall_back_to_basic_fetch!(error)
|
|
35
|
+
@fetch_mutex.synchronize do
|
|
36
|
+
next if @fetch_class == BasicFetch
|
|
37
|
+
|
|
38
|
+
Cogworker.logger.warn do
|
|
39
|
+
"config.fetch = :reliable isn't supported by this Redis (#{error.message[0, 120]}); " \
|
|
40
|
+
'falling back to :basic (a job is lost if its process dies mid-job)'
|
|
41
|
+
end
|
|
42
|
+
@fetch_class = BasicFetch
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
23
46
|
def start!
|
|
24
47
|
@processors.each(&:start!)
|
|
25
48
|
end
|
|
@@ -87,7 +110,7 @@ module Cogworker
|
|
|
87
110
|
end
|
|
88
111
|
|
|
89
112
|
def requeue_unfinished
|
|
90
|
-
moved =
|
|
113
|
+
moved = fetch_class.requeue_in_progress(Cogworker.identity)
|
|
91
114
|
Cogworker.logger.warn { "requeued #{moved} job(s) unfinished at shutdown" } if moved.positive?
|
|
92
115
|
rescue StandardError => e
|
|
93
116
|
Cogworker.logger.error { "Requeueing unfinished jobs failed: #{e.class}: #{e.message}" }
|
|
@@ -7,8 +7,10 @@
|
|
|
7
7
|
-- ARGV[4] = jid the caller will push this slot's job under
|
|
8
8
|
-- ARGV[5] = running-lock TTL (seconds)
|
|
9
9
|
--
|
|
10
|
-
-- Returns 1
|
|
11
|
-
--
|
|
10
|
+
-- Returns {1, <previous last_slot, or "" if none>} if this call won the
|
|
11
|
+
-- claim for this slot (the caller should enqueue the job — and, if that
|
|
12
|
+
-- fails, roll the claim back with ROLLBACK_SCRIPT using that previous
|
|
13
|
+
-- value), 0 otherwise. Composes two guards: `last_slot` stops a
|
|
12
14
|
-- process re-firing the same (or an earlier) slot on a later tick, and the
|
|
13
15
|
-- NX lock breaks the narrow race where two processes both pass the
|
|
14
16
|
-- `last_slot` check before either has written it back.
|
|
@@ -20,7 +22,8 @@ if ARGV[2] == "until_executed" and redis.call("GET", KEYS[1]) then
|
|
|
20
22
|
return 0
|
|
21
23
|
end
|
|
22
24
|
|
|
23
|
-
local
|
|
25
|
+
local previous = redis.call("GET", KEYS[2])
|
|
26
|
+
local last = tonumber(previous or "0")
|
|
24
27
|
if tonumber(ARGV[1]) <= last then
|
|
25
28
|
return 0
|
|
26
29
|
end
|
|
@@ -33,4 +36,4 @@ redis.call("SET", KEYS[2], ARGV[1])
|
|
|
33
36
|
if ARGV[2] == "until_executed" then
|
|
34
37
|
redis.call("SET", KEYS[1], ARGV[4], "EX", ARGV[5])
|
|
35
38
|
end
|
|
36
|
-
return 1
|
|
39
|
+
return {1, previous or ""}
|
|
@@ -4,7 +4,7 @@ module Cogworker
|
|
|
4
4
|
module Periodic
|
|
5
5
|
# Drives the server side of the `periodic:running:<pjid>` lock (used
|
|
6
6
|
# only for `unique: :until_executed` entries; see RunningLock for the
|
|
7
|
-
# whole lifecycle): shortens it to RunningLock
|
|
7
|
+
# whole lifecycle): shortens it to `RunningLock.active_ttl` when the run
|
|
8
8
|
# starts, and releases it on success or on the terminal failed attempt
|
|
9
9
|
# (a failure that will be retried is `Processor#route_failure`'s to
|
|
10
10
|
# extend — only it knows the retry delay).
|
|
@@ -36,7 +36,7 @@ module Cogworker
|
|
|
36
36
|
# the lock just keeps its longer queued TTL until this process's next
|
|
37
37
|
# heartbeat shortens it — within Heartbeat::INTERVAL.
|
|
38
38
|
def shorten_lock(pjid, jid)
|
|
39
|
-
RunningLock.touch(pjid, jid, RunningLock
|
|
39
|
+
RunningLock.touch(pjid, jid, RunningLock.active_ttl)
|
|
40
40
|
rescue StandardError => e
|
|
41
41
|
Cogworker.logger.warn { "couldn't shorten periodic lock for #{pjid}: #{e.class}: #{e.message}" }
|
|
42
42
|
end
|
|
@@ -12,22 +12,47 @@ module Cogworker
|
|
|
12
12
|
# (and release a lock that didn't exist yet) before the lock was
|
|
13
13
|
# written, wedging the entry forever. TTL = `queued_ttl`, covering
|
|
14
14
|
# however long the job may wait in its queue.
|
|
15
|
-
# - shortened to
|
|
15
|
+
# - shortened to `active_ttl` once a processor starts it, then kept alive
|
|
16
16
|
# by that process's Heartbeat every beat — so a run whose process dies
|
|
17
|
-
# (OOM, SIGKILL, shutdown past the drain timeout)
|
|
18
|
-
#
|
|
17
|
+
# (OOM, SIGKILL, shutdown past the drain timeout) can't hold the entry
|
|
18
|
+
# forever. `active_ttl` deliberately outlives the time it takes for
|
|
19
|
+
# that dead process's job to be recovered (`config.orphan_threshold`
|
|
20
|
+
# plus one orphan check), so the lock is still there, still owned by
|
|
21
|
+
# that job, when the job is requeued — at which point `retake` (or ReliableFetch's
|
|
22
|
+
# REQUEUE_SCRIPT) extends it again for the wait in the queue. A shorter
|
|
23
|
+
# TTL let the lock lapse first: the ticker would enqueue the next slot
|
|
24
|
+
# while the recovered run was still to come, i.e. two runs at once.
|
|
19
25
|
# - extended on a non-terminal failure to cover the retry backoff plus
|
|
20
26
|
# `queued_ttl` (`Processor#extend_locks_for_retry` — the job now waits
|
|
21
27
|
# in `cogworker:retry`, not on a process).
|
|
22
28
|
# - deleted on success or on the terminal failure.
|
|
23
29
|
module RunningLock
|
|
24
|
-
|
|
30
|
+
# Lua, prepended to every script that puts a job back on its queue
|
|
31
|
+
# (ReliableFetch::REQUEUE_SCRIPT, BasicFetch::REQUEUE_ENTRY_SCRIPT), so
|
|
32
|
+
# the requeue and the lock move together in one atomic step. Extends
|
|
33
|
+
# the lock if still that job's, takes it if it lapsed, leaves it if a
|
|
34
|
+
# newer run holds it.
|
|
35
|
+
RETAKE_FUNCTION = <<~LUA
|
|
36
|
+
local function retake_running_lock(lock, jid, ttl)
|
|
37
|
+
local owner = redis.call("GET", lock)
|
|
38
|
+
if not owner then
|
|
39
|
+
redis.call("SET", lock, jid, "EX", ttl)
|
|
40
|
+
elseif owner == jid then
|
|
41
|
+
redis.call("EXPIRE", lock, ttl)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
LUA
|
|
25
45
|
|
|
26
46
|
module_function
|
|
27
47
|
|
|
28
48
|
# Whole seconds: it goes straight into `SET ... EX` in claim.lua, which
|
|
29
49
|
# rejects a non-integer (a fractional `unique_lock_ttl` used to make
|
|
30
50
|
# every claim — and so every tick — fail).
|
|
51
|
+
# A method, not a constant: it follows `config.orphan_threshold`.
|
|
52
|
+
def active_ttl
|
|
53
|
+
(Cogworker.config.orphan_threshold + Scheduled::ORPHAN_CHECK_INTERVAL + 60).to_f.ceil
|
|
54
|
+
end
|
|
55
|
+
|
|
31
56
|
def queued_ttl
|
|
32
57
|
Cogworker.config.unique_lock_ttl.to_f.ceil
|
|
33
58
|
end
|
|
@@ -17,6 +17,25 @@ module Cogworker
|
|
|
17
17
|
|
|
18
18
|
CLAIM_SCRIPT = File.read(File.join(__dir__, 'claim.lua'))
|
|
19
19
|
|
|
20
|
+
# Undoes a won claim whose push then failed, so the slot isn't lost:
|
|
21
|
+
# puts `last_slot` back (only if it still is this slot — a later slot
|
|
22
|
+
# may have been claimed since) and drops the per-slot NX lock.
|
|
23
|
+
# KEYS[1] = last_slot, KEYS[2] = slot lock; ARGV[1] = slot, ARGV[2] =
|
|
24
|
+
# previous last_slot ("" if none: then slot - 1, which still lets this
|
|
25
|
+
# slot fire, unlike deleting the key — with catch_up off, a missing
|
|
26
|
+
# last_slot would be re-primed to this very slot instead).
|
|
27
|
+
ROLLBACK_SCRIPT = <<~LUA
|
|
28
|
+
if redis.call("GET", KEYS[1]) == ARGV[1] then
|
|
29
|
+
if ARGV[2] == "" then
|
|
30
|
+
redis.call("SET", KEYS[1], tostring(tonumber(ARGV[1]) - 1))
|
|
31
|
+
else
|
|
32
|
+
redis.call("SET", KEYS[1], ARGV[2])
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
redis.call("DEL", KEYS[2])
|
|
36
|
+
return 1
|
|
37
|
+
LUA
|
|
38
|
+
|
|
20
39
|
def initialize(manager, entries, catch_up: true)
|
|
21
40
|
@manager = manager
|
|
22
41
|
@entries = entries
|
|
@@ -57,8 +76,9 @@ module Cogworker
|
|
|
57
76
|
|
|
58
77
|
# A failed tick is logged and retried next interval rather than ending
|
|
59
78
|
# the thread — a single Redis blip used to silently stop every
|
|
60
|
-
# periodic entry in this process for good. A slot whose claim
|
|
61
|
-
# isn't recorded in `@last_checked_slot
|
|
79
|
+
# periodic entry in this process for good. A slot whose claim or push
|
|
80
|
+
# raised isn't recorded in `@last_checked_slot` (and a failed push
|
|
81
|
+
# rolls its claim back — see #enqueue), so the next tick retries it.
|
|
62
82
|
def run
|
|
63
83
|
until @manager.stopping?
|
|
64
84
|
begin
|
|
@@ -77,13 +97,14 @@ module Cogworker
|
|
|
77
97
|
next if @last_checked_slot[entry.pjid] == slot
|
|
78
98
|
|
|
79
99
|
jid = SecureRandom.hex(12)
|
|
80
|
-
|
|
100
|
+
previous = claim(entry, slot, jid) unless disabled?(entry)
|
|
101
|
+
enqueue(entry, slot, jid, previous) if previous
|
|
81
102
|
@last_checked_slot[entry.pjid] = slot
|
|
82
103
|
end
|
|
83
104
|
end
|
|
84
105
|
|
|
85
106
|
# Web UI "Disable" (`Routes::Schedules`) — skips the claim/enqueue
|
|
86
|
-
# step entirely, short-circuiting before `claim
|
|
107
|
+
# step entirely, short-circuiting before `claim` even runs, so
|
|
87
108
|
# neither `periodic:last_slot:<pjid>` nor the per-slot lock advance
|
|
88
109
|
# while disabled: the Web UI's own "LastRun" column keeps showing the
|
|
89
110
|
# last time it *actually* ran, not a due-but-skipped slot, and
|
|
@@ -93,6 +114,15 @@ module Cogworker
|
|
|
93
114
|
# persisted last_slot) still advances either way, exactly as it
|
|
94
115
|
# already did before this entry ever had a disabled state — it only
|
|
95
116
|
# stops this same tick loop from re-evaluating the same slot twice.
|
|
117
|
+
def rollback_claim(entry, slot, previous_slot)
|
|
118
|
+
Cogworker.config.redis do |c|
|
|
119
|
+
keys = [RedisKeys.periodic_last_slot(entry.pjid), RedisKeys.periodic_lock(entry.pjid, slot)]
|
|
120
|
+
c.eval(ROLLBACK_SCRIPT, keys: keys, argv: [slot, previous_slot])
|
|
121
|
+
end
|
|
122
|
+
rescue StandardError => e
|
|
123
|
+
Cogworker.logger.error { "periodic #{entry.pjid}: couldn't roll back slot #{slot}: #{e.class}: #{e.message}" }
|
|
124
|
+
end
|
|
125
|
+
|
|
96
126
|
def disabled?(entry)
|
|
97
127
|
Cogworker.config.redis { |c| c.sismember(RedisKeys::PERIODIC_DISABLED, entry.pjid) }
|
|
98
128
|
end
|
|
@@ -101,8 +131,10 @@ module Cogworker
|
|
|
101
131
|
@cron_cache[entry.pjid] ||= Fugit::Cron.parse(entry.cron)
|
|
102
132
|
end
|
|
103
133
|
|
|
104
|
-
|
|
105
|
-
|
|
134
|
+
# nil if this process didn't win the slot; otherwise the previous
|
|
135
|
+
# `last_slot` value ("" if none), for #rollback_claim.
|
|
136
|
+
def claim(entry, slot, jid)
|
|
137
|
+
return nil if !@catch_up && priming_first_slot?(entry, slot)
|
|
106
138
|
|
|
107
139
|
result = Cogworker.config.redis do |c|
|
|
108
140
|
c.eval(CLAIM_SCRIPT,
|
|
@@ -110,7 +142,7 @@ module Cogworker
|
|
|
110
142
|
RedisKeys.periodic_lock(entry.pjid, slot)],
|
|
111
143
|
argv: [slot, entry.unique.to_s, LOCK_TTL, jid, RunningLock.queued_ttl])
|
|
112
144
|
end
|
|
113
|
-
result
|
|
145
|
+
result.is_a?(Array) ? result[1].to_s : nil
|
|
114
146
|
end
|
|
115
147
|
|
|
116
148
|
# With catch-up disabled, an entry's very first tick — ever, across
|
|
@@ -140,15 +172,19 @@ module Cogworker
|
|
|
140
172
|
# Only undone here if the push didn't actually produce a job (raised,
|
|
141
173
|
# or a client middleware swallowed it), so a job that never existed
|
|
142
174
|
# can't hold the entry for the whole `RunningLock.queued_ttl`.
|
|
143
|
-
def enqueue(entry, slot, jid)
|
|
175
|
+
def enqueue(entry, slot, jid, previous_slot)
|
|
144
176
|
job = {
|
|
145
177
|
'class' => entry.class_name, 'args' => entry.args, 'retry' => entry.retry,
|
|
146
178
|
'periodic_pjid' => entry.pjid, 'periodic_slot' => slot, 'jid' => jid
|
|
147
179
|
}
|
|
180
|
+
# Lets a requeue after a crash/shutdown re-take this run's lock
|
|
181
|
+
# (Periodic::RunningLock::RETAKE_FUNCTION, in both fetches' requeue scripts).
|
|
182
|
+
job['periodic_until_executed'] = true if entry.until_executed?
|
|
148
183
|
pushed = begin
|
|
149
184
|
Client.push(job)
|
|
150
185
|
rescue StandardError
|
|
151
186
|
RunningLock.release(entry.pjid, jid) if entry.until_executed?
|
|
187
|
+
rollback_claim(entry, slot, previous_slot)
|
|
152
188
|
raise
|
|
153
189
|
end
|
|
154
190
|
return unless entry.until_executed?
|
data/lib/cogworker/processor.rb
CHANGED
|
@@ -16,7 +16,6 @@ module Cogworker
|
|
|
16
16
|
@manager = manager
|
|
17
17
|
@busy = false
|
|
18
18
|
@tid = SecureRandom.hex(6)
|
|
19
|
-
@fetcher = manager.fetch_class.new(manager.queues)
|
|
20
19
|
end
|
|
21
20
|
|
|
22
21
|
def start!
|
|
@@ -54,23 +53,46 @@ module Cogworker
|
|
|
54
53
|
return
|
|
55
54
|
end
|
|
56
55
|
|
|
57
|
-
work =
|
|
56
|
+
work = begin
|
|
57
|
+
fetcher.retrieve_work
|
|
58
|
+
rescue ReliableFetch::UnsupportedError => e
|
|
59
|
+
@manager.fall_back_to_basic_fetch!(e)
|
|
60
|
+
@fetcher = nil
|
|
61
|
+
return
|
|
62
|
+
end
|
|
58
63
|
return unless work
|
|
59
64
|
|
|
60
65
|
# Fetched after shutdown began (e.g. still blocked in BRPOP when
|
|
61
66
|
# `Manager#stop!` requeued unfinished work, which it then popped right
|
|
62
67
|
# back): hand it back rather than start it, since the process is about
|
|
63
68
|
# to exit underneath it.
|
|
64
|
-
return
|
|
69
|
+
return fetcher.give_back(work) if @manager.stopping?
|
|
65
70
|
|
|
66
71
|
@manager.processor_busy!
|
|
67
72
|
@busy = true
|
|
73
|
+
finished = false
|
|
68
74
|
begin
|
|
69
75
|
execute(work)
|
|
76
|
+
finished = true
|
|
70
77
|
ensure
|
|
71
78
|
@busy = false
|
|
72
79
|
@manager.processor_idle!
|
|
73
|
-
acknowledge(work)
|
|
80
|
+
finished ? acknowledge(work) : give_back(work)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# `execute` raising means bookkeeping failed (Redis, mid-way through
|
|
85
|
+
# registering the job or routing its failure to retry/dead) — not the
|
|
86
|
+
# job itself, whose own errors `execute` always handles. Acknowledging
|
|
87
|
+
# here would drop a job that never made it to retry/dead; instead it
|
|
88
|
+
# goes straight back on its queue (it may run again: at-least-once). If
|
|
89
|
+
# even that fails, it simply stays on the in-progress list, which
|
|
90
|
+
# `Manager#stop!`/orphan recovery requeue later.
|
|
91
|
+
def give_back(work)
|
|
92
|
+
fetcher.give_back(work)
|
|
93
|
+
rescue StandardError => e
|
|
94
|
+
Cogworker.logger.error do
|
|
95
|
+
"couldn't hand back jid=#{jid_of(work)} after a failed run (left in progress): #{e.class}: #{e.message}"
|
|
74
96
|
end
|
|
75
97
|
end
|
|
76
98
|
|
|
@@ -82,7 +104,7 @@ module Cogworker
|
|
|
82
104
|
attempts = 0
|
|
83
105
|
begin
|
|
84
106
|
attempts += 1
|
|
85
|
-
|
|
107
|
+
fetcher.acknowledge(work)
|
|
86
108
|
rescue StandardError => e
|
|
87
109
|
if attempts < ACK_ATTEMPTS
|
|
88
110
|
sleep(0.1 * attempts)
|
|
@@ -174,6 +196,12 @@ module Cogworker
|
|
|
174
196
|
Cogworker.logger.error { "unparseable job on queue #{work.queue} moved to dead: #{error.message}" }
|
|
175
197
|
end
|
|
176
198
|
|
|
199
|
+
# Built on first use, not in the constructor (see Manager#fetch_class),
|
|
200
|
+
# and rebuilt after a fall-back to BasicFetch.
|
|
201
|
+
def fetcher
|
|
202
|
+
@fetcher ||= @manager.fetch_class.new(@manager.queues)
|
|
203
|
+
end
|
|
204
|
+
|
|
177
205
|
def build_worker(job)
|
|
178
206
|
klass = Object.const_get(job['class'])
|
|
179
207
|
worker = klass.new
|
data/lib/cogworker/redis_keys.rb
CHANGED
|
@@ -10,6 +10,13 @@ module Cogworker
|
|
|
10
10
|
module RedisKeys
|
|
11
11
|
QUEUE_PREFIX = 'cogworker:queue:'
|
|
12
12
|
IN_PROGRESS_PREFIX = 'cogworker:inprogress:'
|
|
13
|
+
# Every identity that has (or may have) an in-progress list — what
|
|
14
|
+
# orphan recovery walks instead of SCANning the whole keyspace.
|
|
15
|
+
IN_PROGRESS_IDENTITIES = 'cogworker:inprogress_identities'
|
|
16
|
+
# identity => Redis server time (epoch seconds) of its last heartbeat.
|
|
17
|
+
# No TTL, unlike `process(identity)`: it's what tells "dead for a
|
|
18
|
+
# while" apart from "missed a few beats" (Config#orphan_threshold).
|
|
19
|
+
LAST_BEAT = 'cogworker:last_beat'
|
|
13
20
|
QUEUES = 'cogworker:queues'
|
|
14
21
|
SCHEDULE = 'cogworker:schedule'
|
|
15
22
|
RETRY = 'cogworker:retry'
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cogworker
|
|
4
|
+
class ReliableFetch
|
|
5
|
+
# Raised by ReliableFetch#retrieve_work when the server turns out not to
|
|
6
|
+
# know LMOVE (Redis < 6.2) even though the version check at boot let
|
|
7
|
+
# `:reliable` through (e.g. Redis wasn't reachable then). The Processor
|
|
8
|
+
# reacts by switching the whole Manager over to BasicFetch.
|
|
9
|
+
class UnsupportedError < StandardError; end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'digest/sha1'
|
|
4
|
+
|
|
3
5
|
module Cogworker
|
|
4
6
|
# The default fetch (`config.fetch = :reliable`): every job is moved
|
|
5
7
|
# atomically (`LMOVE`, Redis >= 6.2) from its queue onto this process's own
|
|
@@ -8,8 +10,8 @@ module Cogworker
|
|
|
8
10
|
# retry/dead. A job is therefore never only in a processor's memory: if the
|
|
9
11
|
# process dies mid-job (OOM, SIGKILL, a host going away), the job is still
|
|
10
12
|
# 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
|
-
#
|
|
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
|
|
13
15
|
# that was partly done when its process died runs again from the start.
|
|
14
16
|
#
|
|
15
17
|
# `LMOVE` can only take from one list at a time, so there's no blocking
|
|
@@ -19,7 +21,12 @@ module Cogworker
|
|
|
19
21
|
# round sleeps EMPTY_POLL_INTERVAL before the next. Weighting and
|
|
20
22
|
# `Queue#pause!` behave exactly as in BasicFetch.
|
|
21
23
|
class ReliableFetch < BasicFetch
|
|
22
|
-
|
|
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
|
|
23
30
|
MIN_REDIS_VERSION = Gem::Version.new('6.2')
|
|
24
31
|
|
|
25
32
|
# KEYS[1..n-1] = queue keys, in the order to try; KEYS[n] = in-progress list.
|
|
@@ -34,13 +41,21 @@ module Cogworker
|
|
|
34
41
|
return false
|
|
35
42
|
LUA
|
|
36
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
|
+
|
|
37
48
|
# KEYS[1] = an in-progress list; ARGV[1] = queue key prefix; ARGV[2] =
|
|
38
49
|
# queue for a payload whose own `queue` can't be read (the Processor
|
|
39
|
-
# then buries it as unparseable)
|
|
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
|
|
40
52
|
# queue is popped from, so recovered jobs run next, in their original
|
|
41
53
|
# order. Atomic per call: two processes recovering the same list can't
|
|
42
|
-
# both move one job.
|
|
43
|
-
|
|
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
|
|
44
59
|
local moved = 0
|
|
45
60
|
while true do
|
|
46
61
|
local job = redis.call("LPOP", KEYS[1])
|
|
@@ -49,8 +64,14 @@ module Cogworker
|
|
|
49
64
|
end
|
|
50
65
|
local queue = ARGV[2]
|
|
51
66
|
local ok, decoded = pcall(cjson.decode, job)
|
|
52
|
-
if ok and type(decoded) == "table"
|
|
53
|
-
|
|
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
|
|
54
75
|
end
|
|
55
76
|
redis.call("RPUSH", ARGV[1] .. queue, job)
|
|
56
77
|
moved = moved + 1
|
|
@@ -77,28 +98,72 @@ module Cogworker
|
|
|
77
98
|
# on shutdown (`Manager#stop!`) and for dead processes' lists.
|
|
78
99
|
def requeue_in_progress(identity)
|
|
79
100
|
Cogworker.config.redis do |c|
|
|
80
|
-
c.eval(REQUEUE_SCRIPT, keys: [RedisKeys.in_progress(identity)],
|
|
101
|
+
c.eval(REQUEUE_SCRIPT, keys: [RedisKeys.in_progress(identity)],
|
|
102
|
+
argv: [RedisKeys::QUEUE_PREFIX, 'default', RedisKeys.periodic_running(''),
|
|
103
|
+
Periodic::RunningLock.queued_ttl])
|
|
81
104
|
end
|
|
82
105
|
end
|
|
83
106
|
|
|
84
|
-
# Requeues the in-progress list of every process
|
|
85
|
-
#
|
|
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
|
|
86
110
|
# writes its heartbeat before its first fetch (`Heartbeat#start!`), so
|
|
87
|
-
# a live process's list is never mistaken for an orphan.
|
|
88
|
-
|
|
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)
|
|
89
117
|
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)) }
|
|
118
|
+
candidate_identities(scan: scan).sum do |identity|
|
|
119
|
+
next 0 if identity == own || !dead?(identity)
|
|
94
120
|
|
|
95
121
|
requeue_in_progress(identity).tap do |moved|
|
|
122
|
+
forget_if_empty(identity)
|
|
96
123
|
next unless moved.positive?
|
|
97
124
|
|
|
98
125
|
Cogworker.logger.warn { "requeued #{moved} job(s) left in progress by dead process #{identity}" }
|
|
99
126
|
end
|
|
100
127
|
end
|
|
101
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
|
|
102
167
|
end
|
|
103
168
|
|
|
104
169
|
def retrieve_work
|
|
@@ -108,14 +173,13 @@ module Cogworker
|
|
|
108
173
|
return nil
|
|
109
174
|
end
|
|
110
175
|
|
|
111
|
-
result =
|
|
112
|
-
c.eval(FETCH_SCRIPT, keys: keys.shuffle.uniq + [RedisKeys.in_progress(Cogworker.identity)])
|
|
113
|
-
end
|
|
176
|
+
result = run_fetch_script(keys.shuffle.uniq + [RedisKeys.in_progress(Cogworker.identity)])
|
|
114
177
|
unless result
|
|
115
|
-
|
|
178
|
+
idle_sleep
|
|
116
179
|
return nil
|
|
117
180
|
end
|
|
118
181
|
|
|
182
|
+
@idle_interval = nil
|
|
119
183
|
queue_key, raw_job = result
|
|
120
184
|
UnitOfWork.new(queue_key.delete_prefix(RedisKeys::QUEUE_PREFIX), raw_job)
|
|
121
185
|
end
|
|
@@ -133,5 +197,29 @@ module Cogworker
|
|
|
133
197
|
def acknowledge(work)
|
|
134
198
|
Cogworker.config.redis { |c| c.lrem(RedisKeys.in_progress(Cogworker.identity), 1, work.raw_job) }
|
|
135
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
|
|
136
224
|
end
|
|
137
225
|
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
|
|
@@ -58,8 +76,9 @@ module Cogworker
|
|
|
58
76
|
now = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
59
77
|
return if @next_orphan_check && now < @next_orphan_check
|
|
60
78
|
|
|
79
|
+
scan = @next_orphan_check.nil? && @manager.fetch_class == ReliableFetch
|
|
61
80
|
@next_orphan_check = now + ORPHAN_CHECK_INTERVAL
|
|
62
|
-
ReliableFetch.recover_orphans
|
|
81
|
+
ReliableFetch.recover_orphans(scan: scan)
|
|
63
82
|
end
|
|
64
83
|
|
|
65
84
|
def enqueue_due_jobs
|
|
@@ -78,16 +97,18 @@ module Cogworker
|
|
|
78
97
|
Cogworker.config.redis do |c|
|
|
79
98
|
next unless JobUtil.claim_and_requeue(c, set, raw) == :invalid
|
|
80
99
|
|
|
81
|
-
bury(c, set, raw)
|
|
100
|
+
bury(c, set, raw)
|
|
82
101
|
end
|
|
83
102
|
end
|
|
84
103
|
|
|
85
104
|
def bury(conn, set, raw)
|
|
86
105
|
error = JSON::ParserError.new("not a job (JSON object with a queue) in #{set}")
|
|
87
106
|
job = JobUtil.unparseable_job(raw, queue: nil, error: error)
|
|
88
|
-
conn.
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
91
112
|
Cogworker.logger.error { "unparseable entry in #{set} moved to dead: #{raw.to_s[0, 200]}" }
|
|
92
113
|
end
|
|
93
114
|
end
|
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,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
|
|
@@ -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,
|
data/lib/cogworker/web.rb
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'rack'
|
|
4
|
-
require 'rack/session/cookie'
|
|
5
4
|
require 'rack/static'
|
|
6
5
|
require 'securerandom'
|
|
7
6
|
|
|
@@ -105,6 +104,18 @@ module Cogworker
|
|
|
105
104
|
@session_secret ||= ENV.fetch('COGWORKER_SESSION_SECRET') { SecureRandom.hex(32) }
|
|
106
105
|
end
|
|
107
106
|
|
|
107
|
+
# `false` drops the built-in session cookie entirely (it's already
|
|
108
|
+
# skipped automatically whenever the host app provides a session —
|
|
109
|
+
# see OptionalSession). Default true.
|
|
110
|
+
def builtin_session
|
|
111
|
+
@builtin_session.nil? || @builtin_session
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def builtin_session=(enabled)
|
|
115
|
+
@builtin_session = enabled
|
|
116
|
+
@app = nil
|
|
117
|
+
end
|
|
118
|
+
|
|
108
119
|
def session_secret=(secret)
|
|
109
120
|
@session_secret = secret
|
|
110
121
|
@app = nil
|
|
@@ -247,7 +258,9 @@ module Cogworker
|
|
|
247
258
|
|
|
248
259
|
def build_app
|
|
249
260
|
builder = Rack::Builder.new
|
|
250
|
-
|
|
261
|
+
if builtin_session
|
|
262
|
+
builder.use(Cogworker::Web::OptionalSession, secret: session_secret, key: 'cogworker.session')
|
|
263
|
+
end
|
|
251
264
|
# No `cache_control:` (no `immutable`/long `max-age`): these files
|
|
252
265
|
# are plain, unfingerprinted paths that *do* change — every time
|
|
253
266
|
# a vendored stylesheet/script gets edited, or on any gem upgrade —
|
data/lib/cogworker.rb
CHANGED
|
@@ -83,6 +83,17 @@ module Cogworker
|
|
|
83
83
|
!!@server_process
|
|
84
84
|
end
|
|
85
85
|
|
|
86
|
+
# Called right before every `Process.exit!` (which, unlike `exit`,
|
|
87
|
+
# flushes nothing): a custom `Cogworker.logger` writing to a buffered
|
|
88
|
+
# stdout/stderr would otherwise lose its last lines.
|
|
89
|
+
def flush_output!
|
|
90
|
+
[$stdout, $stderr].each do |io|
|
|
91
|
+
io.flush
|
|
92
|
+
rescue StandardError
|
|
93
|
+
nil
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
86
97
|
def server_process!
|
|
87
98
|
@server_process = true
|
|
88
99
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: cogworker
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- vdonec
|
|
@@ -239,6 +239,7 @@ files:
|
|
|
239
239
|
- lib/cogworker/redis_connection.rb
|
|
240
240
|
- lib/cogworker/redis_keys.rb
|
|
241
241
|
- lib/cogworker/reliable_fetch.rb
|
|
242
|
+
- lib/cogworker/reliable_fetch/unsupported_error.rb
|
|
242
243
|
- lib/cogworker/scheduled.rb
|
|
243
244
|
- lib/cogworker/signals.rb
|
|
244
245
|
- lib/cogworker/stats.rb
|
|
@@ -269,6 +270,7 @@ files:
|
|
|
269
270
|
- lib/cogworker/web/assets/phosphor/style.css
|
|
270
271
|
- lib/cogworker/web/history_query.rb
|
|
271
272
|
- lib/cogworker/web/layout.rb
|
|
273
|
+
- lib/cogworker/web/optional_session.rb
|
|
272
274
|
- lib/cogworker/web/router.rb
|
|
273
275
|
- lib/cogworker/web/routes/history.rb
|
|
274
276
|
- lib/cogworker/web/routes/jobs.rb
|