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
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
|
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
3
5
|
module Cogworker
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
+
# `config.fetch = :basic`: pops jobs off Redis lists with one blocking
|
|
7
|
+
# `BRPOP`. Once popped, a job exists only in the processor's memory (and
|
|
8
|
+
# its `cogworker:workers:<identity>` entry, which expires with the
|
|
9
|
+
# process), so a process that dies mid-job loses it — see ReliableFetch,
|
|
10
|
+
# the default, for the alternative.
|
|
11
|
+
#
|
|
12
|
+
# Queue names repeated in the process's queue list represent weight;
|
|
13
|
+
# since BRPOP itself scans its key list
|
|
6
14
|
# strictly left-to-right, weighting is implemented by shuffling the
|
|
7
15
|
# (already-expanded, so repeats survive) key list before every fetch cycle
|
|
8
16
|
# — the more often a name appears, the more likely it lands first.
|
|
@@ -36,6 +44,43 @@ module Cogworker
|
|
|
36
44
|
UnitOfWork.new(queue_key.delete_prefix(RedisKeys::QUEUE_PREFIX), raw_job)
|
|
37
45
|
end
|
|
38
46
|
|
|
47
|
+
# Nothing to do: BRPOP already removed the job from Redis.
|
|
48
|
+
def acknowledge(_work); end
|
|
49
|
+
|
|
50
|
+
# Returns a fetched-but-unstarted job to the end of its queue that's
|
|
51
|
+
# popped next.
|
|
52
|
+
def give_back(work)
|
|
53
|
+
Cogworker.config.redis { |c| c.rpush(RedisKeys.queue(work.queue), work.raw_job) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Shutdown past the drain timeout (`Manager#stop!`): the only record of
|
|
57
|
+
# a still-running job is its `cogworker:workers:<identity>` entry, so
|
|
58
|
+
# that's what gets pushed back — onto the end its queue is popped from,
|
|
59
|
+
# so it runs next. Returns how many. One entry that can't be read is
|
|
60
|
+
# logged and skipped, never allowed to cost the others their requeue.
|
|
61
|
+
def self.requeue_in_progress(identity)
|
|
62
|
+
Cogworker.config.redis do |c|
|
|
63
|
+
c.hvals(RedisKeys.workers(identity)).count do |raw|
|
|
64
|
+
entry = JSON.parse(raw)
|
|
65
|
+
requeue_entry(c, entry.fetch('queue'), entry.fetch('payload'))
|
|
66
|
+
true
|
|
67
|
+
rescue StandardError => e
|
|
68
|
+
Cogworker.logger.error { "couldn't requeue in-flight entry #{raw.to_s[0, 200]}: #{e.class}: #{e.message}" }
|
|
69
|
+
false
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
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
|
+
|
|
39
84
|
private
|
|
40
85
|
|
|
41
86
|
def active_keys
|
|
@@ -46,5 +91,18 @@ module Cogworker
|
|
|
46
91
|
end
|
|
47
92
|
|
|
48
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
|
|
49
107
|
end
|
|
50
108
|
end
|
data/lib/cogworker/cli.rb
CHANGED
|
@@ -8,8 +8,15 @@ module Cogworker
|
|
|
8
8
|
# `-r/--require <file>`, `-C/--config <file>`, `-L/--logfile <file>`,
|
|
9
9
|
# `-q/--queue <name>[,<weight>]` (repeatable).
|
|
10
10
|
class CLI
|
|
11
|
+
# Read (first set wins) for the default environment, and all written
|
|
12
|
+
# back with the resolved one — whichever of them the app's own boot
|
|
13
|
+
# code reads (Rails: RAILS_ENV, then RACK_ENV; Rack apps: RACK_ENV/
|
|
14
|
+
# APP_ENV) sees the same value `-e` asked for.
|
|
15
|
+
ENV_VARS = %w[APP_ENV RAILS_ENV RACK_ENV].freeze
|
|
16
|
+
|
|
11
17
|
def self.parse(argv)
|
|
12
|
-
|
|
18
|
+
environment = ENV_VARS.filter_map { |k| ENV.fetch(k, nil) }.first || 'development'
|
|
19
|
+
options = { queues: [], require_path: nil, environment: environment }
|
|
13
20
|
|
|
14
21
|
OptionParser.new do |o|
|
|
15
22
|
o.on('-e ENV', '--environment ENV') { |v| options[:environment] = v }
|
|
@@ -34,11 +41,14 @@ module Cogworker
|
|
|
34
41
|
# forked child with the exact same flags it was started with.
|
|
35
42
|
def run(argv)
|
|
36
43
|
options = self.class.parse(argv)
|
|
44
|
+
# Before the config file (its ERB may read these) and the app code.
|
|
45
|
+
ENV_VARS.each { |k| ENV[k] = options[:environment] }
|
|
37
46
|
file_config = ConfigLoader.load(options[:config_path])
|
|
38
47
|
|
|
39
48
|
Cogworker.server_process!
|
|
40
49
|
Cogworker.config.concurrency = options[:concurrency] || file_config[:concurrency]&.to_i || Cogworker.config.concurrency
|
|
41
50
|
Cogworker.config.queues = options[:queues].any? ? options[:queues] : (file_config[:queues] || Cogworker.config.queues)
|
|
51
|
+
Cogworker.config.fetch = file_config[:fetch] if file_config[:fetch]
|
|
42
52
|
|
|
43
53
|
redirect_logfile(options[:logfile]) if options[:logfile]
|
|
44
54
|
require_app_code(options[:require_path]) if options[:require_path]
|
data/lib/cogworker/client.rb
CHANGED
|
@@ -50,7 +50,7 @@ module Cogworker
|
|
|
50
50
|
else
|
|
51
51
|
job['enqueued_at'] = Time.now.to_f
|
|
52
52
|
conn.multi do |pipeline|
|
|
53
|
-
pipeline.sadd(RedisKeys::QUEUES, job['queue'])
|
|
53
|
+
pipeline.sadd?(RedisKeys::QUEUES, job['queue'])
|
|
54
54
|
pipeline.lpush(RedisKeys.queue(job['queue']), JSON.generate(job))
|
|
55
55
|
end
|
|
56
56
|
end
|
data/lib/cogworker/config.rb
CHANGED
|
@@ -6,7 +6,10 @@ module Cogworker
|
|
|
6
6
|
# `configure_client` calls, so every block sees and extends the same
|
|
7
7
|
# server/client middleware chains.
|
|
8
8
|
class Config
|
|
9
|
-
|
|
9
|
+
FETCH_MODES = %i[reliable basic].freeze
|
|
10
|
+
|
|
11
|
+
attr_reader :server_chain, :client_chain, :periodic_manager, :redis_options, :fetch, :fetch_idle_max_interval,
|
|
12
|
+
:orphan_threshold
|
|
10
13
|
attr_accessor :concurrency, :queues, :periodic_catch_up, :unique_lock_ttl
|
|
11
14
|
|
|
12
15
|
def initialize
|
|
@@ -31,11 +34,61 @@ module Cogworker
|
|
|
31
34
|
# set higher/lower to match how long a unique job might legitimately
|
|
32
35
|
# run plus however long its retries can take.
|
|
33
36
|
@unique_lock_ttl = 24 * 60 * 60
|
|
37
|
+
# How processors take jobs off their queues — `:reliable` (ReliableFetch,
|
|
38
|
+
# needs Redis >= 6.2; a job survives its process dying) or `:basic`
|
|
39
|
+
# (BasicFetch, one blocking BRPOP; a job is lost if its process dies).
|
|
40
|
+
# On an older Redis, `:reliable` falls back to `:basic` with a warning
|
|
41
|
+
# (Manager#resolve_fetch_class).
|
|
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
|
|
34
54
|
register_default_middleware
|
|
35
55
|
end
|
|
36
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
|
+
|
|
73
|
+
def fetch=(mode)
|
|
74
|
+
resolved = mode.to_s.to_sym
|
|
75
|
+
unless FETCH_MODES.include?(resolved)
|
|
76
|
+
raise ArgumentError, "fetch must be one of #{FETCH_MODES.join(', ')}, got #{mode.inspect}"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
@fetch = resolved
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Also drops an already-built pool: `redis_pool` is memoized on first
|
|
83
|
+
# use, so without this a `redis =` after anything had touched Redis
|
|
84
|
+
# (an init file configuring twice, a test switching databases) was
|
|
85
|
+
# silently ignored. The old pool's connections are closed as they're
|
|
86
|
+
# checked back in, so a caller still holding one finishes normally.
|
|
37
87
|
def redis=(options)
|
|
38
88
|
@redis_options = options
|
|
89
|
+
old_pool = @redis_pool
|
|
90
|
+
@redis_pool = nil
|
|
91
|
+
old_pool&.shutdown(&:close)
|
|
39
92
|
end
|
|
40
93
|
|
|
41
94
|
def redis_pool
|
data/lib/cogworker/heartbeat.rb
CHANGED
|
@@ -15,25 +15,47 @@ 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
|
|
21
22
|
end
|
|
22
23
|
|
|
23
|
-
|
|
24
|
+
# The first beat is synchronous, before any processor fetches a job:
|
|
25
|
+
# ReliableFetch.recover_orphans treats an in-progress list with no live
|
|
26
|
+
# heartbeat behind it as a dead process's, so this process must be
|
|
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 })
|
|
24
33
|
@stopping = false
|
|
34
|
+
until beat_safely
|
|
35
|
+
return false if abort_if.call
|
|
36
|
+
|
|
37
|
+
sleep(STARTUP_RETRY_INTERVAL)
|
|
38
|
+
end
|
|
25
39
|
@beat_thread = Thread.new { beat_loop }
|
|
26
40
|
@signal_thread = Thread.new { subscribe_loop }
|
|
41
|
+
true
|
|
27
42
|
end
|
|
28
43
|
|
|
29
44
|
# Tears down both background threads, not just the Redis keys — leaving
|
|
30
45
|
# them running would leak a thread (and a dedicated pub/sub connection)
|
|
31
46
|
# per Heartbeat instance for the remaining life of the process.
|
|
47
|
+
#
|
|
48
|
+
# The subscriber thread is killed *before* its connection is closed, and
|
|
49
|
+
# never via `unsubscribe`: on redis-rb 4.x, `unsubscribe` from another
|
|
50
|
+
# thread waits on the client's monitor, which the subscriber thread
|
|
51
|
+
# holds for as long as it sits in its blocking read — so it hung
|
|
52
|
+
# forever, and with it every `TERM`. Killing the thread releases the
|
|
53
|
+
# monitor; closing the now-idle connection afterwards is safe on both
|
|
54
|
+
# 4.x and 5.x.
|
|
32
55
|
def stop!
|
|
33
56
|
@stopping = true
|
|
34
57
|
@beat_thread&.kill
|
|
35
|
-
|
|
36
|
-
@signal_thread&.kill
|
|
58
|
+
stop_subscriber!
|
|
37
59
|
cleanup_presence!
|
|
38
60
|
rescue StandardError
|
|
39
61
|
nil
|
|
@@ -41,24 +63,51 @@ module Cogworker
|
|
|
41
63
|
|
|
42
64
|
private
|
|
43
65
|
|
|
66
|
+
def stop_subscriber!
|
|
67
|
+
if @signal_thread && @signal_thread != Thread.current
|
|
68
|
+
@signal_thread.kill
|
|
69
|
+
@signal_thread.join(1)
|
|
70
|
+
end
|
|
71
|
+
@subscribe_client&.close
|
|
72
|
+
rescue StandardError
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
|
|
44
76
|
def cleanup_presence!
|
|
45
77
|
Cogworker.config.redis do |c|
|
|
46
78
|
c.del(RedisKeys.process(Cogworker.identity), RedisKeys.workers(Cogworker.identity))
|
|
47
|
-
c.srem(RedisKeys::PROCESSES, Cogworker.identity)
|
|
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
|
|
48
86
|
end
|
|
49
87
|
end
|
|
50
88
|
|
|
89
|
+
# A failed beat (Redis blip, failover) is logged and retried on the next
|
|
90
|
+
# interval, never allowed to end the loop: a dead heartbeat thread
|
|
91
|
+
# meant this process silently vanished from ProcessSet/the Workers tab
|
|
92
|
+
# once its TTL ran out, while still running jobs.
|
|
51
93
|
def beat_loop
|
|
52
94
|
loop do
|
|
53
|
-
beat
|
|
54
95
|
sleep(INTERVAL)
|
|
96
|
+
beat_safely
|
|
55
97
|
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def beat_safely
|
|
101
|
+
beat
|
|
102
|
+
true
|
|
56
103
|
rescue StandardError => e
|
|
57
|
-
Cogworker.logger.error { "Heartbeat
|
|
104
|
+
Cogworker.logger.error { "Heartbeat failed: #{e.class}: #{e.message}" }
|
|
105
|
+
false
|
|
58
106
|
end
|
|
59
107
|
|
|
60
108
|
def beat
|
|
61
109
|
identity = Cogworker.identity
|
|
110
|
+
reliable = @manager.fetch_class == ReliableFetch
|
|
62
111
|
info = {
|
|
63
112
|
'hostname' => Cogworker.hostname,
|
|
64
113
|
'pid' => ::Process.pid,
|
|
@@ -69,13 +118,35 @@ module Cogworker
|
|
|
69
118
|
}
|
|
70
119
|
|
|
71
120
|
Cogworker.config.redis do |c|
|
|
72
|
-
c.sadd(RedisKeys::PROCESSES, identity)
|
|
121
|
+
c.sadd?(RedisKeys::PROCESSES, identity)
|
|
73
122
|
c.hset(RedisKeys.process(identity),
|
|
74
123
|
'info', JSON.generate(info),
|
|
75
124
|
'busy', @manager.busy_count.to_s,
|
|
76
125
|
'quiet', @manager.quiet?.to_s)
|
|
77
126
|
c.expire(RedisKeys.process(identity), TTL)
|
|
78
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
|
|
132
|
+
touch_running_periodic_locks(c, identity)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Keeps every in-flight `until_executed` periodic run's
|
|
137
|
+
# `periodic:running:<pjid>` lock alive for as long as this process is —
|
|
138
|
+
# see Periodic::RunningLock. Reads the same `cogworker:workers:<identity>`
|
|
139
|
+
# Hash the Workers tab does, so it covers exactly the jobs actually
|
|
140
|
+
# executing here right now.
|
|
141
|
+
def touch_running_periodic_locks(conn, identity)
|
|
142
|
+
conn.hvals(RedisKeys.workers(identity)).each do |raw|
|
|
143
|
+
job = JSON.parse(raw)['payload']
|
|
144
|
+
next unless job.is_a?(Hash) && job['periodic_pjid']
|
|
145
|
+
|
|
146
|
+
Periodic::RunningLock.touch(job['periodic_pjid'], job['jid'], Periodic::RunningLock.active_ttl, conn)
|
|
147
|
+
rescue StandardError => e
|
|
148
|
+
# Per entry: one bad entry mustn't leave the others' locks to expire.
|
|
149
|
+
Cogworker.logger.error { "couldn't refresh periodic lock for #{raw.to_s[0, 200]}: #{e.class}: #{e.message}" }
|
|
79
150
|
end
|
|
80
151
|
end
|
|
81
152
|
|
|
@@ -146,6 +217,7 @@ module Cogworker
|
|
|
146
217
|
def remote_stop!
|
|
147
218
|
@manager.stop!
|
|
148
219
|
cleanup_presence!
|
|
220
|
+
Cogworker.flush_output!
|
|
149
221
|
::Process.exit!(true)
|
|
150
222
|
end
|
|
151
223
|
end
|
data/lib/cogworker/job.rb
CHANGED
|
@@ -50,6 +50,15 @@ module Cogworker
|
|
|
50
50
|
Testing.jobs_for(name).clear
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
+
# Run this class's recorded fake-mode jobs — see `Testing.perform_one`.
|
|
54
|
+
def perform_one
|
|
55
|
+
Testing.perform_one(name)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def drain
|
|
59
|
+
Testing.drain(name)
|
|
60
|
+
end
|
|
61
|
+
|
|
53
62
|
private
|
|
54
63
|
|
|
55
64
|
def job_payload(extra)
|
data/lib/cogworker/job_util.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'json'
|
|
3
4
|
require 'securerandom'
|
|
4
5
|
|
|
5
6
|
module Cogworker
|
|
@@ -56,5 +57,65 @@ module Cogworker
|
|
|
56
57
|
def terminal_failure?(job)
|
|
57
58
|
job['retry_count'].to_i >= max_retries(job)
|
|
58
59
|
end
|
|
60
|
+
|
|
61
|
+
# The job Hash a raw `schedule`/`retry`/`dead` entry would requeue as —
|
|
62
|
+
# or `nil` if it can't be: not JSON, not an object, or no String
|
|
63
|
+
# `queue` to push it onto (e.g. an `unparseable_job` wrapper, whose
|
|
64
|
+
# `queue` may be nil).
|
|
65
|
+
def requeueable(raw)
|
|
66
|
+
job = JSON.parse(raw)
|
|
67
|
+
job.is_a?(Hash) && job['queue'].is_a?(String) && !job['queue'].empty? ? job : nil
|
|
68
|
+
rescue JSON::ParserError, TypeError
|
|
69
|
+
nil
|
|
70
|
+
end
|
|
71
|
+
|
|
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).
|
|
94
|
+
def claim_and_requeue(conn, set, raw)
|
|
95
|
+
job = requeueable(raw)
|
|
96
|
+
return :invalid unless job
|
|
97
|
+
|
|
98
|
+
job['enqueued_at'] = Time.now.to_f
|
|
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
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
UNPARSEABLE_CLASS = '(unparseable)'
|
|
106
|
+
|
|
107
|
+
# A payload that isn't a JSON object can't be run, retried or even
|
|
108
|
+
# attributed to a class. Wrapped into a regular job Hash (the Web UI
|
|
109
|
+
# parses every Dead entry as one) that keeps the original bytes under
|
|
110
|
+
# `raw_payload`, for whoever finds one to bury it in `dead`.
|
|
111
|
+
def unparseable_job(raw, queue:, error:)
|
|
112
|
+
now = Time.now.to_f
|
|
113
|
+
{
|
|
114
|
+
'class' => UNPARSEABLE_CLASS, 'args' => [], 'queue' => queue, 'jid' => SecureRandom.hex(12),
|
|
115
|
+
'retry' => false, 'retry_count' => 0, 'enqueued_at' => now, 'failed_at' => now,
|
|
116
|
+
'error_class' => error.class.name, 'error_message' => error.message.to_s[0, 10_000],
|
|
117
|
+
'raw_payload' => raw.to_s[0, 100_000]
|
|
118
|
+
}
|
|
119
|
+
end
|
|
59
120
|
end
|
|
60
121
|
end
|
data/lib/cogworker/launcher.rb
CHANGED
|
@@ -22,9 +22,11 @@ module Cogworker
|
|
|
22
22
|
def run
|
|
23
23
|
Cogworker.reset_identity!
|
|
24
24
|
install_signal_traps
|
|
25
|
+
resolve_periodic_classes
|
|
26
|
+
return unless start_heartbeat
|
|
27
|
+
|
|
25
28
|
@manager.start!
|
|
26
29
|
@scheduled.start!
|
|
27
|
-
@heartbeat.start!
|
|
28
30
|
@ticker.start!
|
|
29
31
|
log_startup_info
|
|
30
32
|
watch_signals
|
|
@@ -43,6 +45,41 @@ module Cogworker
|
|
|
43
45
|
|
|
44
46
|
private
|
|
45
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
|
+
|
|
65
|
+
# Resolves every periodic entry's class here, on the main thread, before
|
|
66
|
+
# any processor thread exists. `Processor#build_worker` otherwise does
|
|
67
|
+
# the first `const_get` lazily on whichever processor thread picks the
|
|
68
|
+
# job up — and with an app autoloader that isn't thread-safe, two
|
|
69
|
+
# threads racing to autoload the same class (or its base class) can see
|
|
70
|
+
# it half-defined (`undefined method 'perform'`). A class that can't be
|
|
71
|
+
# resolved is only logged: every run of that entry will fail the same
|
|
72
|
+
# way (and show up in Dead/History), but it shouldn't keep the rest of
|
|
73
|
+
# the process from booting. Regular job classes aren't known up front —
|
|
74
|
+
# eager-load the app in the init file for those (see README).
|
|
75
|
+
def resolve_periodic_classes
|
|
76
|
+
@config.periodic_manager.entries.map(&:class_name).uniq.each do |name|
|
|
77
|
+
Object.const_get(name)
|
|
78
|
+
rescue NameError => e
|
|
79
|
+
Cogworker.logger.warn { "periodic job class #{name} can't be resolved: #{e.message}" }
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
46
83
|
# Logged once all components are up, so the operator's console shows a
|
|
47
84
|
# single summary (version/identity/pid/concurrency/queues/redis target)
|
|
48
85
|
# right at the point the process is actually ready to pull work, rather
|
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
|
@@ -6,6 +6,7 @@ module Cogworker
|
|
|
6
6
|
class Manager
|
|
7
7
|
def initialize(config = Cogworker.config)
|
|
8
8
|
@config = config
|
|
9
|
+
@fetch_mutex = Mutex.new
|
|
9
10
|
@processors = Array.new(config.concurrency) { Processor.new(self) }
|
|
10
11
|
@quiet = false
|
|
11
12
|
@stopping = false
|
|
@@ -17,6 +18,31 @@ module Cogworker
|
|
|
17
18
|
@config.queues
|
|
18
19
|
end
|
|
19
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
|
+
|
|
20
46
|
def start!
|
|
21
47
|
@processors.each(&:start!)
|
|
22
48
|
end
|
|
@@ -49,10 +75,13 @@ module Cogworker
|
|
|
49
75
|
@busy_mutex.synchronize { @busy_count -= 1 }
|
|
50
76
|
end
|
|
51
77
|
|
|
52
|
-
# Waits (up to `timeout` seconds) for in-flight jobs to finish, then
|
|
53
|
-
#
|
|
54
|
-
#
|
|
55
|
-
#
|
|
78
|
+
# Waits (up to `timeout` seconds) for in-flight jobs to finish, then puts
|
|
79
|
+
# any job still unfinished back on its queue and returns. Does not
|
|
80
|
+
# force-kill lingering threads past the deadline — a thread stuck past
|
|
81
|
+
# the deadline is the caller's (Launcher's) problem to decide whether to
|
|
82
|
+
# hard-exit the process; its job is already safely requeued by then,
|
|
83
|
+
# rather than lost with the process (it may therefore run twice, if the
|
|
84
|
+
# thread does still finish it).
|
|
56
85
|
def stop!(timeout: 25)
|
|
57
86
|
@quiet = true
|
|
58
87
|
@stopping = true
|
|
@@ -61,10 +90,43 @@ module Cogworker
|
|
|
61
90
|
remaining = deadline - monotonic_now
|
|
62
91
|
p.thread&.join([remaining, 0].max)
|
|
63
92
|
end
|
|
93
|
+
wait_for_fetching_processors
|
|
94
|
+
requeue_unfinished
|
|
64
95
|
end
|
|
65
96
|
|
|
66
97
|
private
|
|
67
98
|
|
|
99
|
+
# A processor that isn't running a job is at most one fetch timeout away
|
|
100
|
+
# from noticing `stopping?` and exiting — but until it does, it can
|
|
101
|
+
# still pop a job (BRPOP blocks for up to BasicFetch::TIMEOUT), and one
|
|
102
|
+
# popped after `requeue_unfinished` had already run would be lost with
|
|
103
|
+
# the process. Waiting those out first means the only threads left
|
|
104
|
+
# alive are ones stuck inside a job, which never fetch again.
|
|
105
|
+
def wait_for_fetching_processors
|
|
106
|
+
deadline = monotonic_now + BasicFetch::TIMEOUT + 1
|
|
107
|
+
@processors.reject(&:busy?).each do |p|
|
|
108
|
+
p.thread&.join([deadline - monotonic_now, 0].max)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def requeue_unfinished
|
|
113
|
+
moved = fetch_class.requeue_in_progress(Cogworker.identity)
|
|
114
|
+
Cogworker.logger.warn { "requeued #{moved} job(s) unfinished at shutdown" } if moved.positive?
|
|
115
|
+
rescue StandardError => e
|
|
116
|
+
Cogworker.logger.error { "Requeueing unfinished jobs failed: #{e.class}: #{e.message}" }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def resolve_fetch_class
|
|
120
|
+
return BasicFetch if @config.fetch == :basic
|
|
121
|
+
return ReliableFetch if ReliableFetch.supported?
|
|
122
|
+
|
|
123
|
+
Cogworker.logger.warn do
|
|
124
|
+
"config.fetch = :reliable needs Redis >= #{ReliableFetch::MIN_REDIS_VERSION}; falling back to :basic " \
|
|
125
|
+
'(a job is lost if its process dies mid-job)'
|
|
126
|
+
end
|
|
127
|
+
BasicFetch
|
|
128
|
+
end
|
|
129
|
+
|
|
68
130
|
# Fully qualified: inside the Cogworker namespace, a bare `Process` would
|
|
69
131
|
# resolve to Cogworker::Process (the ProcessSet entry wrapper in api.rb),
|
|
70
132
|
# not the ::Process kernel module.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cogworker
|
|
4
|
+
# Compare-and-act on a lock key whose value is its owner's jid
|
|
5
|
+
# (`periodic:running:<pjid>`, `cogworker:unique:<digest>`): the EXPIRE/DEL
|
|
6
|
+
# only happens if the key still holds that exact jid, atomically — so a
|
|
7
|
+
# stray older run can never shorten, extend or release a lock that has
|
|
8
|
+
# since passed to a newer one.
|
|
9
|
+
module OwnedKey
|
|
10
|
+
EXPIRE_IF_OWNED = <<~LUA
|
|
11
|
+
if redis.call("GET", KEYS[1]) == ARGV[1] then
|
|
12
|
+
return redis.call("EXPIRE", KEYS[1], ARGV[2])
|
|
13
|
+
end
|
|
14
|
+
return 0
|
|
15
|
+
LUA
|
|
16
|
+
|
|
17
|
+
DELETE_IF_OWNED = <<~LUA
|
|
18
|
+
if redis.call("GET", KEYS[1]) == ARGV[1] then
|
|
19
|
+
return redis.call("DEL", KEYS[1])
|
|
20
|
+
end
|
|
21
|
+
return 0
|
|
22
|
+
LUA
|
|
23
|
+
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
def expire(key, owner, ttl, conn = nil)
|
|
27
|
+
run(conn, EXPIRE_IF_OWNED, key, [owner, ttl.ceil])
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def delete(key, owner, conn = nil)
|
|
31
|
+
run(conn, DELETE_IF_OWNED, key, [owner])
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def run(conn, script, key, argv)
|
|
35
|
+
return conn.eval(script, keys: [key], argv: argv) if conn
|
|
36
|
+
|
|
37
|
+
Cogworker.config.redis { |c| c.eval(script, keys: [key], argv: argv) }
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|