cogworker 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/cogworker/basic_fetch.rb +37 -2
- data/lib/cogworker/cli.rb +11 -1
- data/lib/cogworker/client.rb +1 -1
- data/lib/cogworker/config.rb +26 -1
- data/lib/cogworker/heartbeat.rb +54 -6
- data/lib/cogworker/job.rb +9 -0
- data/lib/cogworker/job_util.rb +56 -0
- data/lib/cogworker/launcher.rb +20 -1
- data/lib/cogworker/manager.rb +43 -4
- data/lib/cogworker/owned_key.rb +40 -0
- data/lib/cogworker/periodic/claim.lua +9 -0
- data/lib/cogworker/periodic/release_middleware.rb +31 -13
- data/lib/cogworker/periodic/running_lock.rb +44 -0
- data/lib/cogworker/periodic/ticker.rb +39 -10
- data/lib/cogworker/process.rb +2 -2
- data/lib/cogworker/process_set.rb +1 -1
- data/lib/cogworker/processor.rb +116 -14
- data/lib/cogworker/queue.rb +4 -4
- data/lib/cogworker/redis_keys.rb +2 -0
- data/lib/cogworker/reliable_fetch.rb +137 -0
- data/lib/cogworker/scheduled.rb +37 -10
- data/lib/cogworker/stats.rb +1 -1
- data/lib/cogworker/status.rb +5 -1
- data/lib/cogworker/swarm.rb +64 -4
- data/lib/cogworker/testing/empty_queue_error.rb +9 -0
- data/lib/cogworker/testing.rb +27 -3
- data/lib/cogworker/unique_jobs/client_middleware.rb +13 -4
- data/lib/cogworker/unique_jobs/release_middleware.rb +4 -1
- data/lib/cogworker/version.rb +1 -1
- data/lib/cogworker/web/history_query.rb +181 -0
- data/lib/cogworker/web/layout.rb +3 -3
- data/lib/cogworker/web/routes/history.rb +111 -68
- data/lib/cogworker/web/routes/jobs.rb +17 -17
- data/lib/cogworker/web/routes/overview.rb +6 -12
- data/lib/cogworker/web/routes/schedules.rb +2 -2
- data/lib/cogworker/web.rb +21 -8
- data/lib/cogworker.rb +5 -1
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 13e1ad0fec77124674f55dc3e1f7213bd2a73834ebb474261ed82a337fc1bb3e
|
|
4
|
+
data.tar.gz: ed51fed2854035c83016b3180fa7872772e6f60bb2dd84e44089485a536f81e4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0d78dd7a7c4bbdf8bfc8c8619467922e732879908562b1ac99fed71ce111a8a4925136e87a9c4e56cb5abed57867d76f06c5d929fcb822156d4fd0add7a182d9
|
|
7
|
+
data.tar.gz: 265d890a8f7d65b180dcb9a0f00f0d2ba6daf16fa9044f7bdb94b88b2cbeced1f6d61f5e11c86d5b107d281bbbc47591b54cb3fc6820f7a7fecc4732dec451d2
|
|
@@ -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,33 @@ 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
|
+
c.rpush(RedisKeys.queue(entry.fetch('queue')), JSON.generate(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
|
+
|
|
39
74
|
private
|
|
40
75
|
|
|
41
76
|
def active_keys
|
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,9 @@ 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
|
|
10
12
|
attr_accessor :concurrency, :queues, :periodic_catch_up, :unique_lock_ttl
|
|
11
13
|
|
|
12
14
|
def initialize
|
|
@@ -31,11 +33,34 @@ module Cogworker
|
|
|
31
33
|
# set higher/lower to match how long a unique job might legitimately
|
|
32
34
|
# run plus however long its retries can take.
|
|
33
35
|
@unique_lock_ttl = 24 * 60 * 60
|
|
36
|
+
# How processors take jobs off their queues — `:reliable` (ReliableFetch,
|
|
37
|
+
# needs Redis >= 6.2; a job survives its process dying) or `:basic`
|
|
38
|
+
# (BasicFetch, one blocking BRPOP; a job is lost if its process dies).
|
|
39
|
+
# On an older Redis, `:reliable` falls back to `:basic` with a warning
|
|
40
|
+
# (Manager#resolve_fetch_class).
|
|
41
|
+
@fetch = :reliable
|
|
34
42
|
register_default_middleware
|
|
35
43
|
end
|
|
36
44
|
|
|
45
|
+
def fetch=(mode)
|
|
46
|
+
resolved = mode.to_s.to_sym
|
|
47
|
+
unless FETCH_MODES.include?(resolved)
|
|
48
|
+
raise ArgumentError, "fetch must be one of #{FETCH_MODES.join(', ')}, got #{mode.inspect}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
@fetch = resolved
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Also drops an already-built pool: `redis_pool` is memoized on first
|
|
55
|
+
# use, so without this a `redis =` after anything had touched Redis
|
|
56
|
+
# (an init file configuring twice, a test switching databases) was
|
|
57
|
+
# silently ignored. The old pool's connections are closed as they're
|
|
58
|
+
# checked back in, so a caller still holding one finishes normally.
|
|
37
59
|
def redis=(options)
|
|
38
60
|
@redis_options = options
|
|
61
|
+
old_pool = @redis_pool
|
|
62
|
+
@redis_pool = nil
|
|
63
|
+
old_pool&.shutdown(&:close)
|
|
39
64
|
end
|
|
40
65
|
|
|
41
66
|
def redis_pool
|
data/lib/cogworker/heartbeat.rb
CHANGED
|
@@ -20,8 +20,13 @@ module Cogworker
|
|
|
20
20
|
@manager = manager
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
+
# The first beat is synchronous, before any processor fetches a job:
|
|
24
|
+
# ReliableFetch.recover_orphans treats an in-progress list with no live
|
|
25
|
+
# 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.
|
|
23
27
|
def start!
|
|
24
28
|
@stopping = false
|
|
29
|
+
beat_safely
|
|
25
30
|
@beat_thread = Thread.new { beat_loop }
|
|
26
31
|
@signal_thread = Thread.new { subscribe_loop }
|
|
27
32
|
end
|
|
@@ -29,11 +34,18 @@ module Cogworker
|
|
|
29
34
|
# Tears down both background threads, not just the Redis keys — leaving
|
|
30
35
|
# them running would leak a thread (and a dedicated pub/sub connection)
|
|
31
36
|
# per Heartbeat instance for the remaining life of the process.
|
|
37
|
+
#
|
|
38
|
+
# The subscriber thread is killed *before* its connection is closed, and
|
|
39
|
+
# never via `unsubscribe`: on redis-rb 4.x, `unsubscribe` from another
|
|
40
|
+
# thread waits on the client's monitor, which the subscriber thread
|
|
41
|
+
# holds for as long as it sits in its blocking read — so it hung
|
|
42
|
+
# forever, and with it every `TERM`. Killing the thread releases the
|
|
43
|
+
# monitor; closing the now-idle connection afterwards is safe on both
|
|
44
|
+
# 4.x and 5.x.
|
|
32
45
|
def stop!
|
|
33
46
|
@stopping = true
|
|
34
47
|
@beat_thread&.kill
|
|
35
|
-
|
|
36
|
-
@signal_thread&.kill
|
|
48
|
+
stop_subscriber!
|
|
37
49
|
cleanup_presence!
|
|
38
50
|
rescue StandardError
|
|
39
51
|
nil
|
|
@@ -41,20 +53,38 @@ module Cogworker
|
|
|
41
53
|
|
|
42
54
|
private
|
|
43
55
|
|
|
56
|
+
def stop_subscriber!
|
|
57
|
+
if @signal_thread && @signal_thread != Thread.current
|
|
58
|
+
@signal_thread.kill
|
|
59
|
+
@signal_thread.join(1)
|
|
60
|
+
end
|
|
61
|
+
@subscribe_client&.close
|
|
62
|
+
rescue StandardError
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
|
|
44
66
|
def cleanup_presence!
|
|
45
67
|
Cogworker.config.redis do |c|
|
|
46
68
|
c.del(RedisKeys.process(Cogworker.identity), RedisKeys.workers(Cogworker.identity))
|
|
47
|
-
c.srem(RedisKeys::PROCESSES, Cogworker.identity)
|
|
69
|
+
c.srem?(RedisKeys::PROCESSES, Cogworker.identity)
|
|
48
70
|
end
|
|
49
71
|
end
|
|
50
72
|
|
|
73
|
+
# A failed beat (Redis blip, failover) is logged and retried on the next
|
|
74
|
+
# interval, never allowed to end the loop: a dead heartbeat thread
|
|
75
|
+
# meant this process silently vanished from ProcessSet/the Workers tab
|
|
76
|
+
# once its TTL ran out, while still running jobs.
|
|
51
77
|
def beat_loop
|
|
52
78
|
loop do
|
|
53
|
-
beat
|
|
54
79
|
sleep(INTERVAL)
|
|
80
|
+
beat_safely
|
|
55
81
|
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def beat_safely
|
|
85
|
+
beat
|
|
56
86
|
rescue StandardError => e
|
|
57
|
-
Cogworker.logger.error { "Heartbeat
|
|
87
|
+
Cogworker.logger.error { "Heartbeat failed: #{e.class}: #{e.message}" }
|
|
58
88
|
end
|
|
59
89
|
|
|
60
90
|
def beat
|
|
@@ -69,13 +99,31 @@ module Cogworker
|
|
|
69
99
|
}
|
|
70
100
|
|
|
71
101
|
Cogworker.config.redis do |c|
|
|
72
|
-
c.sadd(RedisKeys::PROCESSES, identity)
|
|
102
|
+
c.sadd?(RedisKeys::PROCESSES, identity)
|
|
73
103
|
c.hset(RedisKeys.process(identity),
|
|
74
104
|
'info', JSON.generate(info),
|
|
75
105
|
'busy', @manager.busy_count.to_s,
|
|
76
106
|
'quiet', @manager.quiet?.to_s)
|
|
77
107
|
c.expire(RedisKeys.process(identity), TTL)
|
|
78
108
|
c.expire(RedisKeys.workers(identity), TTL)
|
|
109
|
+
touch_running_periodic_locks(c, identity)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Keeps every in-flight `until_executed` periodic run's
|
|
114
|
+
# `periodic:running:<pjid>` lock alive for as long as this process is —
|
|
115
|
+
# see Periodic::RunningLock. Reads the same `cogworker:workers:<identity>`
|
|
116
|
+
# Hash the Workers tab does, so it covers exactly the jobs actually
|
|
117
|
+
# executing here right now.
|
|
118
|
+
def touch_running_periodic_locks(conn, identity)
|
|
119
|
+
conn.hvals(RedisKeys.workers(identity)).each do |raw|
|
|
120
|
+
job = JSON.parse(raw)['payload']
|
|
121
|
+
next unless job.is_a?(Hash) && job['periodic_pjid']
|
|
122
|
+
|
|
123
|
+
Periodic::RunningLock.touch(job['periodic_pjid'], job['jid'], Periodic::RunningLock::ACTIVE_TTL, conn)
|
|
124
|
+
rescue StandardError => e
|
|
125
|
+
# Per entry: one bad entry mustn't leave the others' locks to expire.
|
|
126
|
+
Cogworker.logger.error { "couldn't refresh periodic lock for #{raw.to_s[0, 200]}: #{e.class}: #{e.message}" }
|
|
79
127
|
end
|
|
80
128
|
end
|
|
81
129
|
|
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,60 @@ 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 : nil
|
|
68
|
+
rescue JSON::ParserError, TypeError
|
|
69
|
+
nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Validate, then claim, then requeue — in that order, so an entry that
|
|
73
|
+
# can't be requeued is never `zrem`'d and lost (it stays where it is for
|
|
74
|
+
# the caller to deal with). Returns `:requeued`, `:gone` (someone else
|
|
75
|
+
# won the `zrem` first) or `:invalid`. The one implementation behind
|
|
76
|
+
# `Scheduled#graduate` and every Web UI retry action.
|
|
77
|
+
def claim_and_requeue(conn, set, raw)
|
|
78
|
+
job = requeueable(raw)
|
|
79
|
+
return :invalid unless job
|
|
80
|
+
return :gone unless conn.zrem(set, raw)
|
|
81
|
+
|
|
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
|
+
job['enqueued_at'] = Time.now.to_f
|
|
94
|
+
conn.multi do |tx|
|
|
95
|
+
tx.sadd?(RedisKeys::QUEUES, job['queue'])
|
|
96
|
+
tx.lpush(RedisKeys.queue(job['queue']), JSON.generate(job))
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
UNPARSEABLE_CLASS = '(unparseable)'
|
|
101
|
+
|
|
102
|
+
# A payload that isn't a JSON object can't be run, retried or even
|
|
103
|
+
# attributed to a class. Wrapped into a regular job Hash (the Web UI
|
|
104
|
+
# parses every Dead entry as one) that keeps the original bytes under
|
|
105
|
+
# `raw_payload`, for whoever finds one to bury it in `dead`.
|
|
106
|
+
def unparseable_job(raw, queue:, error:)
|
|
107
|
+
now = Time.now.to_f
|
|
108
|
+
{
|
|
109
|
+
'class' => UNPARSEABLE_CLASS, 'args' => [], 'queue' => queue, 'jid' => SecureRandom.hex(12),
|
|
110
|
+
'retry' => false, 'retry_count' => 0, 'enqueued_at' => now, 'failed_at' => now,
|
|
111
|
+
'error_class' => error.class.name, 'error_message' => error.message.to_s[0, 10_000],
|
|
112
|
+
'raw_payload' => raw.to_s[0, 100_000]
|
|
113
|
+
}
|
|
114
|
+
end
|
|
59
115
|
end
|
|
60
116
|
end
|
data/lib/cogworker/launcher.rb
CHANGED
|
@@ -22,9 +22,10 @@ module Cogworker
|
|
|
22
22
|
def run
|
|
23
23
|
Cogworker.reset_identity!
|
|
24
24
|
install_signal_traps
|
|
25
|
+
resolve_periodic_classes
|
|
26
|
+
@heartbeat.start! # first: see Heartbeat#start!
|
|
25
27
|
@manager.start!
|
|
26
28
|
@scheduled.start!
|
|
27
|
-
@heartbeat.start!
|
|
28
29
|
@ticker.start!
|
|
29
30
|
log_startup_info
|
|
30
31
|
watch_signals
|
|
@@ -43,6 +44,24 @@ module Cogworker
|
|
|
43
44
|
|
|
44
45
|
private
|
|
45
46
|
|
|
47
|
+
# Resolves every periodic entry's class here, on the main thread, before
|
|
48
|
+
# any processor thread exists. `Processor#build_worker` otherwise does
|
|
49
|
+
# the first `const_get` lazily on whichever processor thread picks the
|
|
50
|
+
# job up — and with an app autoloader that isn't thread-safe, two
|
|
51
|
+
# threads racing to autoload the same class (or its base class) can see
|
|
52
|
+
# it half-defined (`undefined method 'perform'`). A class that can't be
|
|
53
|
+
# resolved is only logged: every run of that entry will fail the same
|
|
54
|
+
# way (and show up in Dead/History), but it shouldn't keep the rest of
|
|
55
|
+
# the process from booting. Regular job classes aren't known up front —
|
|
56
|
+
# eager-load the app in the init file for those (see README).
|
|
57
|
+
def resolve_periodic_classes
|
|
58
|
+
@config.periodic_manager.entries.map(&:class_name).uniq.each do |name|
|
|
59
|
+
Object.const_get(name)
|
|
60
|
+
rescue NameError => e
|
|
61
|
+
Cogworker.logger.warn { "periodic job class #{name} can't be resolved: #{e.message}" }
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
46
65
|
# Logged once all components are up, so the operator's console shows a
|
|
47
66
|
# single summary (version/identity/pid/concurrency/queues/redis target)
|
|
48
67
|
# right at the point the process is actually ready to pull work, rather
|
data/lib/cogworker/manager.rb
CHANGED
|
@@ -4,8 +4,11 @@ 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
|
+
|
|
7
9
|
def initialize(config = Cogworker.config)
|
|
8
10
|
@config = config
|
|
11
|
+
@fetch_class = resolve_fetch_class
|
|
9
12
|
@processors = Array.new(config.concurrency) { Processor.new(self) }
|
|
10
13
|
@quiet = false
|
|
11
14
|
@stopping = false
|
|
@@ -49,10 +52,13 @@ module Cogworker
|
|
|
49
52
|
@busy_mutex.synchronize { @busy_count -= 1 }
|
|
50
53
|
end
|
|
51
54
|
|
|
52
|
-
# Waits (up to `timeout` seconds) for in-flight jobs to finish, then
|
|
53
|
-
#
|
|
54
|
-
#
|
|
55
|
-
#
|
|
55
|
+
# Waits (up to `timeout` seconds) for in-flight jobs to finish, then puts
|
|
56
|
+
# any job still unfinished back on its queue and returns. Does not
|
|
57
|
+
# force-kill lingering threads past the deadline — a thread stuck past
|
|
58
|
+
# the deadline is the caller's (Launcher's) problem to decide whether to
|
|
59
|
+
# hard-exit the process; its job is already safely requeued by then,
|
|
60
|
+
# rather than lost with the process (it may therefore run twice, if the
|
|
61
|
+
# thread does still finish it).
|
|
56
62
|
def stop!(timeout: 25)
|
|
57
63
|
@quiet = true
|
|
58
64
|
@stopping = true
|
|
@@ -61,10 +67,43 @@ module Cogworker
|
|
|
61
67
|
remaining = deadline - monotonic_now
|
|
62
68
|
p.thread&.join([remaining, 0].max)
|
|
63
69
|
end
|
|
70
|
+
wait_for_fetching_processors
|
|
71
|
+
requeue_unfinished
|
|
64
72
|
end
|
|
65
73
|
|
|
66
74
|
private
|
|
67
75
|
|
|
76
|
+
# A processor that isn't running a job is at most one fetch timeout away
|
|
77
|
+
# from noticing `stopping?` and exiting — but until it does, it can
|
|
78
|
+
# still pop a job (BRPOP blocks for up to BasicFetch::TIMEOUT), and one
|
|
79
|
+
# popped after `requeue_unfinished` had already run would be lost with
|
|
80
|
+
# the process. Waiting those out first means the only threads left
|
|
81
|
+
# alive are ones stuck inside a job, which never fetch again.
|
|
82
|
+
def wait_for_fetching_processors
|
|
83
|
+
deadline = monotonic_now + BasicFetch::TIMEOUT + 1
|
|
84
|
+
@processors.reject(&:busy?).each do |p|
|
|
85
|
+
p.thread&.join([deadline - monotonic_now, 0].max)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def requeue_unfinished
|
|
90
|
+
moved = @fetch_class.requeue_in_progress(Cogworker.identity)
|
|
91
|
+
Cogworker.logger.warn { "requeued #{moved} job(s) unfinished at shutdown" } if moved.positive?
|
|
92
|
+
rescue StandardError => e
|
|
93
|
+
Cogworker.logger.error { "Requeueing unfinished jobs failed: #{e.class}: #{e.message}" }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def resolve_fetch_class
|
|
97
|
+
return BasicFetch if @config.fetch == :basic
|
|
98
|
+
return ReliableFetch if ReliableFetch.supported?
|
|
99
|
+
|
|
100
|
+
Cogworker.logger.warn do
|
|
101
|
+
"config.fetch = :reliable needs Redis >= #{ReliableFetch::MIN_REDIS_VERSION}; falling back to :basic " \
|
|
102
|
+
'(a job is lost if its process dies mid-job)'
|
|
103
|
+
end
|
|
104
|
+
BasicFetch
|
|
105
|
+
end
|
|
106
|
+
|
|
68
107
|
# Fully qualified: inside the Cogworker namespace, a bare `Process` would
|
|
69
108
|
# resolve to Cogworker::Process (the ProcessSet entry wrapper in api.rb),
|
|
70
109
|
# 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
|
|
@@ -4,12 +4,18 @@
|
|
|
4
4
|
-- ARGV[1] = slot (epoch int)
|
|
5
5
|
-- ARGV[2] = unique mode ("until_executed" or "")
|
|
6
6
|
-- ARGV[3] = lock TTL (seconds)
|
|
7
|
+
-- ARGV[4] = jid the caller will push this slot's job under
|
|
8
|
+
-- ARGV[5] = running-lock TTL (seconds)
|
|
7
9
|
--
|
|
8
10
|
-- Returns 1 if this call won the claim for this slot (the caller should
|
|
9
11
|
-- enqueue the job), 0 otherwise. Composes two guards: `last_slot` stops a
|
|
10
12
|
-- process re-firing the same (or an earlier) slot on a later tick, and the
|
|
11
13
|
-- NX lock breaks the narrow race where two processes both pass the
|
|
12
14
|
-- `last_slot` check before either has written it back.
|
|
15
|
+
--
|
|
16
|
+
-- For `until_executed`, the running lock is written here, in the same
|
|
17
|
+
-- atomic step as the claim and before the job exists anywhere — see
|
|
18
|
+
-- Periodic::RunningLock for why it must never be written after the push.
|
|
13
19
|
if ARGV[2] == "until_executed" and redis.call("GET", KEYS[1]) then
|
|
14
20
|
return 0
|
|
15
21
|
end
|
|
@@ -24,4 +30,7 @@ if not redis.call("SET", KEYS[3], "1", "NX", "EX", ARGV[3]) then
|
|
|
24
30
|
end
|
|
25
31
|
|
|
26
32
|
redis.call("SET", KEYS[2], ARGV[1])
|
|
33
|
+
if ARGV[2] == "until_executed" then
|
|
34
|
+
redis.call("SET", KEYS[1], ARGV[4], "EX", ARGV[5])
|
|
35
|
+
end
|
|
27
36
|
return 1
|
|
@@ -2,25 +2,43 @@
|
|
|
2
2
|
|
|
3
3
|
module Cogworker
|
|
4
4
|
module Periodic
|
|
5
|
-
#
|
|
6
|
-
# `unique: :until_executed` entries
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
5
|
+
# Drives the server side of the `periodic:running:<pjid>` lock (used
|
|
6
|
+
# only for `unique: :until_executed` entries; see RunningLock for the
|
|
7
|
+
# whole lifecycle): shortens it to RunningLock::ACTIVE_TTL when the run
|
|
8
|
+
# starts, and releases it on success or on the terminal failed attempt
|
|
9
|
+
# (a failure that will be retried is `Processor#route_failure`'s to
|
|
10
|
+
# extend — only it knows the retry delay).
|
|
11
|
+
# A no-op for any job that isn't periodic-scheduled (`job['periodic_pjid']`
|
|
12
|
+
# absent) — and every call is a no-op on a lock owned by another jid, so
|
|
13
|
+
# it's harmless for a non-`until_executed` entry too. Registered
|
|
14
|
+
# unconditionally in every Config, not gated on whether
|
|
15
|
+
# `config.periodic` is actually used, so it never becomes a hidden
|
|
16
|
+
# dependency of the (separate) job status layer.
|
|
11
17
|
class ReleaseMiddleware
|
|
12
18
|
def call(_worker, job, _queue)
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
19
|
+
pjid = job['periodic_pjid']
|
|
20
|
+
return yield unless pjid
|
|
21
|
+
|
|
22
|
+
shorten_lock(pjid, job['jid'])
|
|
23
|
+
begin
|
|
24
|
+
yield
|
|
25
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
26
|
+
RunningLock.release(pjid, job['jid']) if JobUtil.terminal_failure?(job)
|
|
27
|
+
raise e
|
|
28
|
+
end
|
|
29
|
+
RunningLock.release(pjid, job['jid'])
|
|
18
30
|
end
|
|
19
31
|
|
|
20
32
|
private
|
|
21
33
|
|
|
22
|
-
|
|
23
|
-
|
|
34
|
+
# Best-effort: failing here must not stop the job from running (it
|
|
35
|
+
# would be routed to retry/dead as if it had failed). If it does fail,
|
|
36
|
+
# the lock just keeps its longer queued TTL until this process's next
|
|
37
|
+
# heartbeat shortens it — within Heartbeat::INTERVAL.
|
|
38
|
+
def shorten_lock(pjid, jid)
|
|
39
|
+
RunningLock.touch(pjid, jid, RunningLock::ACTIVE_TTL)
|
|
40
|
+
rescue StandardError => e
|
|
41
|
+
Cogworker.logger.warn { "couldn't shorten periodic lock for #{pjid}: #{e.class}: #{e.message}" }
|
|
24
42
|
end
|
|
25
43
|
end
|
|
26
44
|
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cogworker
|
|
4
|
+
module Periodic
|
|
5
|
+
# `periodic:running:<pjid>` — the "an earlier run of this entry hasn't
|
|
6
|
+
# finished yet" lock behind `unique: :until_executed` periodic entries.
|
|
7
|
+
#
|
|
8
|
+
# Lifecycle (every write keyed on the owning jid, so a stray older run
|
|
9
|
+
# can never shorten, extend or release a newer run's lock):
|
|
10
|
+
# - set by `claim.lua`, atomically with the slot claim itself, *before*
|
|
11
|
+
# the job is pushed — setting it after the push let a fast job finish
|
|
12
|
+
# (and release a lock that didn't exist yet) before the lock was
|
|
13
|
+
# written, wedging the entry forever. TTL = `queued_ttl`, covering
|
|
14
|
+
# however long the job may wait in its queue.
|
|
15
|
+
# - shortened to ACTIVE_TTL once a processor starts it, then kept alive
|
|
16
|
+
# by that process's Heartbeat every beat — so a run whose process dies
|
|
17
|
+
# (OOM, SIGKILL, shutdown past the drain timeout) frees the entry
|
|
18
|
+
# within ACTIVE_TTL instead of never.
|
|
19
|
+
# - extended on a non-terminal failure to cover the retry backoff plus
|
|
20
|
+
# `queued_ttl` (`Processor#extend_locks_for_retry` — the job now waits
|
|
21
|
+
# in `cogworker:retry`, not on a process).
|
|
22
|
+
# - deleted on success or on the terminal failure.
|
|
23
|
+
module RunningLock
|
|
24
|
+
ACTIVE_TTL = 60
|
|
25
|
+
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
# Whole seconds: it goes straight into `SET ... EX` in claim.lua, which
|
|
29
|
+
# rejects a non-integer (a fractional `unique_lock_ttl` used to make
|
|
30
|
+
# every claim — and so every tick — fail).
|
|
31
|
+
def queued_ttl
|
|
32
|
+
Cogworker.config.unique_lock_ttl.to_f.ceil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def touch(pjid, jid, ttl, conn = nil)
|
|
36
|
+
OwnedKey.expire(RedisKeys.periodic_running(pjid), jid, ttl, conn)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def release(pjid, jid, conn = nil)
|
|
40
|
+
OwnedKey.delete(RedisKeys.periodic_running(pjid), jid, conn)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|