cogworker 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/cogworker/attempts.rb +1 -1
- data/lib/cogworker/basic_fetch.rb +46 -3
- data/lib/cogworker/config.rb +29 -1
- data/lib/cogworker/heartbeat.rb +38 -4
- data/lib/cogworker/history/storage.rb +2 -2
- data/lib/cogworker/job_util.rb +50 -23
- data/lib/cogworker/launcher.rb +26 -2
- data/lib/cogworker/logging.rb +4 -0
- data/lib/cogworker/lua_script.rb +30 -0
- data/lib/cogworker/manager.rb +27 -4
- data/lib/cogworker/owned_key.rb +2 -2
- data/lib/cogworker/periodic/claim.lua +7 -4
- data/lib/cogworker/periodic/release_middleware.rb +2 -2
- data/lib/cogworker/periodic/running_lock.rb +29 -4
- data/lib/cogworker/periodic/ticker.rb +65 -11
- data/lib/cogworker/processor.rb +161 -49
- data/lib/cogworker/redis_keys.rb +7 -0
- data/lib/cogworker/reliable_fetch/unsupported_error.rb +11 -0
- data/lib/cogworker/reliable_fetch.rb +222 -43
- data/lib/cogworker/scheduled.rb +38 -7
- data/lib/cogworker/status/server_middleware.rb +1 -1
- data/lib/cogworker/swarm.rb +1 -0
- data/lib/cogworker/version.rb +1 -1
- data/lib/cogworker/web/optional_session.rb +32 -0
- data/lib/cogworker/web/routes/jobs.rb +58 -23
- data/lib/cogworker/web.rb +16 -2
- data/lib/cogworker.rb +11 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 680a06059bb5853e9d7843ff6d5313d7cd9431b222b846b496c9ca965d6b8844
|
|
4
|
+
data.tar.gz: fd688cdc23fa56e86e6d4c50a7f319e98698d4c6166d8213359c38eb0f94aa71
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fc5d3f1a358e0c826b2801ddd73f2b039b31b1e29616a38947da6df0cd85edfdf3b9b8c877e7123fc6283617f7f34317b9503c76db4eeb89a881677e335c5cfb
|
|
7
|
+
data.tar.gz: 486aff800ab67a15ce9c815f659a70ee95abcfd28218e7c862329a26a0f3643dce3a3c24dc79c539531a9b29b50f745e81482e617ba908c7d80ea131fe7b6d60
|
data/lib/cogworker/attempts.rb
CHANGED
|
@@ -24,7 +24,7 @@ module Cogworker
|
|
|
24
24
|
def record(jid, attempt:, error:, outcome:)
|
|
25
25
|
entry = JSON.generate(
|
|
26
26
|
'attempt' => attempt, 'failed_at' => Time.now.to_f, 'outcome' => outcome,
|
|
27
|
-
'error_class' => error.class.name, 'error_message' => error
|
|
27
|
+
'error_class' => error.class.name, 'error_message' => JobUtil.error_message(error)
|
|
28
28
|
)
|
|
29
29
|
key = RedisKeys.job_attempts(jid)
|
|
30
30
|
Cogworker.config.redis do |c|
|
|
@@ -17,8 +17,12 @@ module Cogworker
|
|
|
17
17
|
class BasicFetch
|
|
18
18
|
TIMEOUT = 2 # seconds; also how often a stopped/quieted processor notices and exits its fetch loop.
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
# `stopping:` lets a fetcher cut its own waits short once shutdown has
|
|
21
|
+
# begun (Processor passes the Manager's `stopping?`), so a long idle
|
|
22
|
+
# pause can't hold up `Manager#stop!`.
|
|
23
|
+
def initialize(queues, stopping: -> { false })
|
|
21
24
|
@queue_keys = Array(queues).map { |q| RedisKeys.queue(q) }
|
|
25
|
+
@stopping = stopping
|
|
22
26
|
end
|
|
23
27
|
|
|
24
28
|
# Re-reads `cogworker:paused_queues` on every single fetch (not once at
|
|
@@ -33,7 +37,7 @@ module Cogworker
|
|
|
33
37
|
def retrieve_work
|
|
34
38
|
keys = active_keys
|
|
35
39
|
if keys.empty?
|
|
36
|
-
|
|
40
|
+
interruptible_sleep(TIMEOUT)
|
|
37
41
|
return nil
|
|
38
42
|
end
|
|
39
43
|
|
|
@@ -47,6 +51,12 @@ module Cogworker
|
|
|
47
51
|
# Nothing to do: BRPOP already removed the job from Redis.
|
|
48
52
|
def acknowledge(_work); end
|
|
49
53
|
|
|
54
|
+
# `Processor#interrupt`: the job ran, recording its failure failed —
|
|
55
|
+
# file it under `set` (retry/dead) at `score` instead.
|
|
56
|
+
def interrupt(_work, set, score, payload)
|
|
57
|
+
Cogworker.config.redis { |c| c.zadd(set, score, payload) }
|
|
58
|
+
end
|
|
59
|
+
|
|
50
60
|
# Returns a fetched-but-unstarted job to the end of its queue that's
|
|
51
61
|
# popped next.
|
|
52
62
|
def give_back(work)
|
|
@@ -62,7 +72,7 @@ module Cogworker
|
|
|
62
72
|
Cogworker.config.redis do |c|
|
|
63
73
|
c.hvals(RedisKeys.workers(identity)).count do |raw|
|
|
64
74
|
entry = JSON.parse(raw)
|
|
65
|
-
c
|
|
75
|
+
requeue_entry(c, entry.fetch('queue'), entry.fetch('payload'))
|
|
66
76
|
true
|
|
67
77
|
rescue StandardError => e
|
|
68
78
|
Cogworker.logger.error { "couldn't requeue in-flight entry #{raw.to_s[0, 200]}: #{e.class}: #{e.message}" }
|
|
@@ -71,8 +81,28 @@ module Cogworker
|
|
|
71
81
|
end
|
|
72
82
|
end
|
|
73
83
|
|
|
84
|
+
def self.requeue_entry(conn, queue, job)
|
|
85
|
+
queue_key = RedisKeys.queue(queue)
|
|
86
|
+
retake = job['periodic_until_executed'] && job['periodic_pjid'] && job['jid']
|
|
87
|
+
lock_key = retake ? RedisKeys.periodic_running(job['periodic_pjid']) : queue_key
|
|
88
|
+
LuaScript.run(conn, REQUEUE_ENTRY_SCRIPT, keys: [queue_key, lock_key],
|
|
89
|
+
argv: [JSON.generate(job), retake ? '1' : '0', job['jid'].to_s,
|
|
90
|
+
Periodic::RunningLock.queued_ttl])
|
|
91
|
+
end
|
|
92
|
+
private_class_method :requeue_entry
|
|
93
|
+
|
|
74
94
|
private
|
|
75
95
|
|
|
96
|
+
def interruptible_sleep(seconds)
|
|
97
|
+
deadline = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + seconds
|
|
98
|
+
until @stopping.call
|
|
99
|
+
left = deadline - ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
100
|
+
break if left <= 0
|
|
101
|
+
|
|
102
|
+
sleep([left, 0.25].min)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
76
106
|
def active_keys
|
|
77
107
|
paused = Cogworker.config.redis { |c| c.smembers(RedisKeys::PAUSED_QUEUES) }
|
|
78
108
|
return @queue_keys if paused.empty?
|
|
@@ -81,5 +111,18 @@ module Cogworker
|
|
|
81
111
|
end
|
|
82
112
|
|
|
83
113
|
UnitOfWork = Struct.new(:queue, :raw_job)
|
|
114
|
+
|
|
115
|
+
# KEYS[1] = the queue, KEYS[2] = the job's periodic running lock (or the
|
|
116
|
+
# queue again when there's none to take); ARGV[1] = payload, ARGV[2] =
|
|
117
|
+
# "1" to re-take that lock, ARGV[3] = jid, ARGV[4] = lock TTL. Requeue
|
|
118
|
+
# and lock in one step: as two commands, a failure in between left the
|
|
119
|
+
# job queued without its lock (and miscounted).
|
|
120
|
+
REQUEUE_ENTRY_SCRIPT = Periodic::RunningLock::RETAKE_FUNCTION + <<~LUA
|
|
121
|
+
redis.call("RPUSH", KEYS[1], ARGV[1])
|
|
122
|
+
if ARGV[2] == "1" then
|
|
123
|
+
retake_running_lock(KEYS[2], ARGV[3], ARGV[4])
|
|
124
|
+
end
|
|
125
|
+
return 1
|
|
126
|
+
LUA
|
|
84
127
|
end
|
|
85
128
|
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,10 +125,24 @@ 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, redis_now(c))
|
|
131
|
+
end
|
|
109
132
|
touch_running_periodic_locks(c, identity)
|
|
110
133
|
end
|
|
111
134
|
end
|
|
112
135
|
|
|
136
|
+
# Redis's clock, not this host's (so host clock skew doesn't matter to
|
|
137
|
+
# orphan detection) — falling back to this host's where `TIME` isn't
|
|
138
|
+
# allowed (some proxies/managed services): better a skewed stamp than a
|
|
139
|
+
# process that can never complete its first beat.
|
|
140
|
+
def redis_now(conn)
|
|
141
|
+
conn.time.first
|
|
142
|
+
rescue Redis::CommandError
|
|
143
|
+
Time.now.to_i
|
|
144
|
+
end
|
|
145
|
+
|
|
113
146
|
# Keeps every in-flight `until_executed` periodic run's
|
|
114
147
|
# `periodic:running:<pjid>` lock alive for as long as this process is —
|
|
115
148
|
# see Periodic::RunningLock. Reads the same `cogworker:workers:<identity>`
|
|
@@ -120,7 +153,7 @@ module Cogworker
|
|
|
120
153
|
job = JSON.parse(raw)['payload']
|
|
121
154
|
next unless job.is_a?(Hash) && job['periodic_pjid']
|
|
122
155
|
|
|
123
|
-
Periodic::RunningLock.touch(job['periodic_pjid'], job['jid'], Periodic::RunningLock
|
|
156
|
+
Periodic::RunningLock.touch(job['periodic_pjid'], job['jid'], Periodic::RunningLock.active_ttl, conn)
|
|
124
157
|
rescue StandardError => e
|
|
125
158
|
# Per entry: one bad entry mustn't leave the others' locks to expire.
|
|
126
159
|
Cogworker.logger.error { "couldn't refresh periodic lock for #{raw.to_s[0, 200]}: #{e.class}: #{e.message}" }
|
|
@@ -194,6 +227,7 @@ module Cogworker
|
|
|
194
227
|
def remote_stop!
|
|
195
228
|
@manager.stop!
|
|
196
229
|
cleanup_presence!
|
|
230
|
+
Cogworker.flush_output!
|
|
197
231
|
::Process.exit!(true)
|
|
198
232
|
end
|
|
199
233
|
end
|
|
@@ -30,8 +30,8 @@ module Cogworker
|
|
|
30
30
|
}
|
|
31
31
|
if error
|
|
32
32
|
entry['error_class'] = error.class.name
|
|
33
|
-
entry['error_message'] = error
|
|
34
|
-
entry['backtrace'] = (error.backtrace || []).first(200)
|
|
33
|
+
entry['error_message'] = JobUtil.error_message(error)
|
|
34
|
+
entry['backtrace'] = (error.backtrace || []).first(200).map { |line| JobUtil.safe_string(line) }
|
|
35
35
|
end
|
|
36
36
|
|
|
37
37
|
raw = JSON.generate(entry)
|
data/lib/cogworker/job_util.rb
CHANGED
|
@@ -64,37 +64,64 @@ 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
|
-
|
|
83
|
-
|
|
98
|
+
job['enqueued_at'] = Time.now.to_f
|
|
99
|
+
won = LuaScript.run(conn, 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
|
|
84
103
|
end
|
|
85
104
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
#
|
|
89
|
-
#
|
|
90
|
-
#
|
|
91
|
-
#
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
105
|
+
ERROR_MESSAGE_LIMIT = 10_000
|
|
106
|
+
|
|
107
|
+
# Valid UTF-8, whatever came in — every string this gem stores from
|
|
108
|
+
# outside its control (an exception's message, a raw payload) goes
|
|
109
|
+
# through here before `JSON.generate`, which raises on invalid UTF-8.
|
|
110
|
+
# An unchecked `raise "bad \xff".b` used to make every attempt to record
|
|
111
|
+
# the failure itself raise, deterministically. Strings in another
|
|
112
|
+
# (valid) encoding are transcoded; binary/broken ones are scrubbed.
|
|
113
|
+
def safe_string(value, limit = nil)
|
|
114
|
+
str = value.to_s
|
|
115
|
+
str = if [Encoding::UTF_8, Encoding::BINARY, Encoding::US_ASCII].include?(str.encoding)
|
|
116
|
+
str.dup.force_encoding(Encoding::UTF_8).scrub
|
|
117
|
+
else
|
|
118
|
+
str.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
|
|
119
|
+
end
|
|
120
|
+
limit ? str[0, limit] : str
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def error_message(error)
|
|
124
|
+
safe_string(error.message, ERROR_MESSAGE_LIMIT)
|
|
98
125
|
end
|
|
99
126
|
|
|
100
127
|
UNPARSEABLE_CLASS = '(unparseable)'
|
|
@@ -108,8 +135,8 @@ module Cogworker
|
|
|
108
135
|
{
|
|
109
136
|
'class' => UNPARSEABLE_CLASS, 'args' => [], 'queue' => queue, 'jid' => SecureRandom.hex(12),
|
|
110
137
|
'retry' => false, 'retry_count' => 0, 'enqueued_at' => now, 'failed_at' => now,
|
|
111
|
-
'error_class' => error.class.name, 'error_message' => error
|
|
112
|
-
'raw_payload' => raw
|
|
138
|
+
'error_class' => error.class.name, 'error_message' => error_message(error),
|
|
139
|
+
'raw_payload' => safe_string(raw, 100_000)
|
|
113
140
|
}
|
|
114
141
|
end
|
|
115
142
|
end
|
data/lib/cogworker/launcher.rb
CHANGED
|
@@ -13,8 +13,14 @@ module Cogworker
|
|
|
13
13
|
@manager = Manager.new(config)
|
|
14
14
|
@scheduled = Scheduled.new(@manager)
|
|
15
15
|
@heartbeat = Heartbeat.new(@manager)
|
|
16
|
+
# The first tick waits for the first orphan check: after the whole
|
|
17
|
+
# fleet was down longer than a running `until_executed` job's lock
|
|
18
|
+
# lives, the ticker would otherwise claim that entry's next slot before
|
|
19
|
+
# recovery had requeued the interrupted run (and re-taken its lock) —
|
|
20
|
+
# two runs at once.
|
|
16
21
|
@ticker = Periodic::Ticker.new(
|
|
17
|
-
@manager, config.periodic_manager.entries, catch_up: config.periodic_catch_up
|
|
22
|
+
@manager, config.periodic_manager.entries, catch_up: config.periodic_catch_up,
|
|
23
|
+
ready: -> { @scheduled.recovered_once? }
|
|
18
24
|
)
|
|
19
25
|
@signal_queue = ::Queue.new
|
|
20
26
|
end
|
|
@@ -23,7 +29,8 @@ module Cogworker
|
|
|
23
29
|
Cogworker.reset_identity!
|
|
24
30
|
install_signal_traps
|
|
25
31
|
resolve_periodic_classes
|
|
26
|
-
|
|
32
|
+
return unless start_heartbeat
|
|
33
|
+
|
|
27
34
|
@manager.start!
|
|
28
35
|
@scheduled.start!
|
|
29
36
|
@ticker.start!
|
|
@@ -44,6 +51,23 @@ module Cogworker
|
|
|
44
51
|
|
|
45
52
|
private
|
|
46
53
|
|
|
54
|
+
# First, and blocking until it succeeds: see Heartbeat#start!. A stop
|
|
55
|
+
# signal arriving while Redis is still unreachable ends the wait (and
|
|
56
|
+
# the process) without ever starting anything; a quiet one is applied
|
|
57
|
+
# and the wait goes on.
|
|
58
|
+
def start_heartbeat
|
|
59
|
+
until @heartbeat.start!(abort_if: -> { !@signal_queue.empty? })
|
|
60
|
+
case @signal_queue.pop
|
|
61
|
+
when :stop
|
|
62
|
+
Cogworker.logger.info { 'Received stop signal before startup completed' }
|
|
63
|
+
stop!
|
|
64
|
+
return false
|
|
65
|
+
when :quiet then quiet!
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
true
|
|
69
|
+
end
|
|
70
|
+
|
|
47
71
|
# Resolves every periodic entry's class here, on the main thread, before
|
|
48
72
|
# any processor thread exists. `Processor#build_worker` otherwise does
|
|
49
73
|
# 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|
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest/sha1'
|
|
4
|
+
|
|
5
|
+
module Cogworker
|
|
6
|
+
# Runs a Lua script by its SHA1 (EVALSHA) instead of shipping the whole
|
|
7
|
+
# source on every call, falling back to EVAL — which also loads it into
|
|
8
|
+
# the server's script cache for next time — on NOSCRIPT (first use, a
|
|
9
|
+
# restarted or failed-over server, SCRIPT FLUSH). Every script in this
|
|
10
|
+
# gem goes through here.
|
|
11
|
+
module LuaScript
|
|
12
|
+
SHAS = {} # source => sha1; memoized, the source strings are frozen constants
|
|
13
|
+
SHAS_MUTEX = Mutex.new
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def run(conn, source, keys: [], argv: [])
|
|
18
|
+
begin
|
|
19
|
+
return conn.evalsha(sha(source), keys: keys, argv: argv)
|
|
20
|
+
rescue Redis::CommandError => e
|
|
21
|
+
raise unless e.message.start_with?('NOSCRIPT')
|
|
22
|
+
end
|
|
23
|
+
conn.eval(source, keys: keys, argv: argv)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def sha(source)
|
|
27
|
+
SHAS_MUTEX.synchronize { SHAS[source] ||= Digest::SHA1.hexdigest(source) }
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
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, not in the constructor (that needed Redis
|
|
22
|
+
# just to build a Manager). First use is normally the Heartbeat's first
|
|
23
|
+
# beat; if Redis can't be reached then, ReliableFetch.supported? raises
|
|
24
|
+
# and nothing is memoized — the beat fails and is retried, and this
|
|
25
|
+
# resolves again next time — rather than memoizing a guess.
|
|
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}" }
|
data/lib/cogworker/owned_key.rb
CHANGED
|
@@ -32,9 +32,9 @@ module Cogworker
|
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
def run(conn, script, key, argv)
|
|
35
|
-
return
|
|
35
|
+
return LuaScript.run(conn, script, keys: [key], argv: argv) if conn
|
|
36
36
|
|
|
37
|
-
Cogworker.config.redis { |c|
|
|
37
|
+
Cogworker.config.redis { |c| LuaScript.run(c, script, keys: [key], argv: argv) }
|
|
38
38
|
end
|
|
39
39
|
end
|
|
40
40
|
end
|
|
@@ -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
|