cogworker 0.4.0 → 0.5.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/attempts.rb +1 -1
- data/lib/cogworker/basic_fetch.rb +26 -5
- data/lib/cogworker/config.rb +13 -1
- data/lib/cogworker/heartbeat.rb +33 -10
- data/lib/cogworker/history/storage.rb +2 -2
- data/lib/cogworker/job_util.rb +49 -9
- data/lib/cogworker/key_guard.rb +92 -0
- data/lib/cogworker/launcher.rb +8 -1
- data/lib/cogworker/lua_script.rb +30 -0
- data/lib/cogworker/manager.rb +63 -6
- data/lib/cogworker/owned_key.rb +2 -2
- data/lib/cogworker/periodic/ticker.rb +122 -25
- data/lib/cogworker/processor.rb +194 -59
- data/lib/cogworker/redis_keys.rb +9 -0
- data/lib/cogworker/reliable_fetch.rb +480 -87
- data/lib/cogworker/scheduled.rb +100 -14
- data/lib/cogworker/status/server_middleware.rb +1 -1
- data/lib/cogworker/version.rb +1 -1
- data/lib/cogworker/web/optional_session.rb +7 -0
- data/lib/cogworker/web.rb +5 -4
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e908112cee4c6a9f98588b6cca08c292437d1713ec351ac6287e1f5ef605c452
|
|
4
|
+
data.tar.gz: 0c271ea997d7ddf51df5aa5a16b45cb08c0b3231f71dcdb253956160c589bde3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fb759fcff852bfa57315e4428250daeac0a0ae27771dc108e1879f3847616c073ce5d2fa8e079a77d73a51d7db3f18154d5cd6c7ea45c69b1282163cf817aa75
|
|
7
|
+
data.tar.gz: caea49e5286f9bf98e93c2931c2e8a77e18b9cce59e88c7ee7854f80bfe30b7b45649a148ed08c605ae87faeeb5ce87ae7f6fe23a523625db1728db1f215b120
|
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)
|
|
@@ -58,7 +68,8 @@ module Cogworker
|
|
|
58
68
|
# that's what gets pushed back — onto the end its queue is popped from,
|
|
59
69
|
# so it runs next. Returns how many. One entry that can't be read is
|
|
60
70
|
# logged and skipped, never allowed to cost the others their requeue.
|
|
61
|
-
|
|
71
|
+
# (`except:` is ReliableFetch's — there's no in-progress list here.)
|
|
72
|
+
def self.requeue_in_progress(identity, except: [], keep: []) # rubocop:disable Lint/UnusedMethodArgument
|
|
62
73
|
Cogworker.config.redis do |c|
|
|
63
74
|
c.hvals(RedisKeys.workers(identity)).count do |raw|
|
|
64
75
|
entry = JSON.parse(raw)
|
|
@@ -75,14 +86,24 @@ module Cogworker
|
|
|
75
86
|
queue_key = RedisKeys.queue(queue)
|
|
76
87
|
retake = job['periodic_until_executed'] && job['periodic_pjid'] && job['jid']
|
|
77
88
|
lock_key = retake ? RedisKeys.periodic_running(job['periodic_pjid']) : queue_key
|
|
78
|
-
|
|
79
|
-
|
|
89
|
+
LuaScript.run(conn, REQUEUE_ENTRY_SCRIPT, keys: [queue_key, lock_key],
|
|
90
|
+
argv: [JSON.generate(job), retake ? '1' : '0', job['jid'].to_s,
|
|
80
91
|
Periodic::RunningLock.queued_ttl])
|
|
81
92
|
end
|
|
82
93
|
private_class_method :requeue_entry
|
|
83
94
|
|
|
84
95
|
private
|
|
85
96
|
|
|
97
|
+
def interruptible_sleep(seconds)
|
|
98
|
+
deadline = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) + seconds
|
|
99
|
+
until @stopping.call
|
|
100
|
+
left = deadline - ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
101
|
+
break if left <= 0
|
|
102
|
+
|
|
103
|
+
sleep([left, 0.25].min)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
86
107
|
def active_keys
|
|
87
108
|
paused = Cogworker.config.redis { |c| c.smembers(RedisKeys::PAUSED_QUEUES) }
|
|
88
109
|
return @queue_keys if paused.empty?
|
data/lib/cogworker/config.rb
CHANGED
|
@@ -9,7 +9,7 @@ module Cogworker
|
|
|
9
9
|
FETCH_MODES = %i[reliable basic].freeze
|
|
10
10
|
|
|
11
11
|
attr_reader :server_chain, :client_chain, :periodic_manager, :redis_options, :fetch, :fetch_idle_max_interval,
|
|
12
|
-
:orphan_threshold
|
|
12
|
+
:orphan_threshold, :max_orphanings
|
|
13
13
|
attr_accessor :concurrency, :queues, :periodic_catch_up, :unique_lock_ttl
|
|
14
14
|
|
|
15
15
|
def initialize
|
|
@@ -51,9 +51,21 @@ module Cogworker
|
|
|
51
51
|
# failover, a long GVL-holding call) — each of which runs its jobs
|
|
52
52
|
# twice; lower: a really crashed process's jobs come back sooner.
|
|
53
53
|
@orphan_threshold = 300
|
|
54
|
+
# How many times a job may be recovered from a process that died
|
|
55
|
+
# while running it before it goes to dead instead (ReliableFetch). The
|
|
56
|
+
# count can't single out the culprit: every job running in a process
|
|
57
|
+
# that crashed is counted, so keep this above the number of crashes an
|
|
58
|
+
# innocent job might plausibly sit through.
|
|
59
|
+
@max_orphanings = 3
|
|
54
60
|
register_default_middleware
|
|
55
61
|
end
|
|
56
62
|
|
|
63
|
+
def max_orphanings=(count)
|
|
64
|
+
raise ArgumentError, "max_orphanings must be a positive Integer, got #{count.inspect}" unless count.is_a?(Integer) && count.positive?
|
|
65
|
+
|
|
66
|
+
@max_orphanings = count
|
|
67
|
+
end
|
|
68
|
+
|
|
57
69
|
def orphan_threshold=(seconds)
|
|
58
70
|
unless seconds.is_a?(Numeric) && seconds.positive?
|
|
59
71
|
raise ArgumentError, "orphan_threshold must be a positive number of seconds, got #{seconds.inspect}"
|
data/lib/cogworker/heartbeat.rb
CHANGED
|
@@ -117,30 +117,53 @@ module Cogworker
|
|
|
117
117
|
'rss_kb' => current_rss_kb
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
+
# The presence key is what makes this process alive to everyone else
|
|
121
|
+
# (and what Heartbeat#start! waits for) — the one write a beat can't
|
|
122
|
+
# do without. Everything else is written on its own, best-effort: one
|
|
123
|
+
# shared key of the wrong type used to fail every beat of every
|
|
124
|
+
# process, so no new process could start anywhere in the fleet.
|
|
120
125
|
Cogworker.config.redis do |c|
|
|
121
|
-
c.sadd?(RedisKeys::PROCESSES, identity)
|
|
122
126
|
c.hset(RedisKeys.process(identity),
|
|
123
127
|
'info', JSON.generate(info),
|
|
124
128
|
'busy', @manager.busy_count.to_s,
|
|
125
129
|
'quiet', @manager.quiet?.to_s)
|
|
126
130
|
c.expire(RedisKeys.process(identity), TTL)
|
|
127
|
-
c.
|
|
131
|
+
secondary('process registry') { c.sadd?(RedisKeys::PROCESSES, identity) }
|
|
132
|
+
secondary('workers expiry') { c.expire(RedisKeys.workers(identity), TTL) }
|
|
128
133
|
if reliable
|
|
129
|
-
c.sadd?(RedisKeys::IN_PROGRESS_IDENTITIES, identity)
|
|
130
|
-
c.hset(RedisKeys::LAST_BEAT, identity, c
|
|
134
|
+
secondary('in-progress registry') { c.sadd?(RedisKeys::IN_PROGRESS_IDENTITIES, identity) }
|
|
135
|
+
secondary('last-beat stamp') { c.hset(RedisKeys::LAST_BEAT, identity, redis_now(c)) }
|
|
131
136
|
end
|
|
132
137
|
touch_running_periodic_locks(c, identity)
|
|
133
138
|
end
|
|
134
139
|
end
|
|
135
140
|
|
|
141
|
+
def secondary(what)
|
|
142
|
+
yield
|
|
143
|
+
rescue Redis::BaseConnectionError
|
|
144
|
+
raise # Redis itself is gone: the beat as a whole failed
|
|
145
|
+
rescue StandardError => e
|
|
146
|
+
Cogworker.logger.error { "Heartbeat: #{what} not written: #{e.class}: #{e.message}" }
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Redis's clock, not this host's (so host clock skew doesn't matter to
|
|
150
|
+
# orphan detection) — falling back to this host's where `TIME` isn't
|
|
151
|
+
# allowed (some proxies/managed services): better a skewed stamp than a
|
|
152
|
+
# process that can never complete its first beat.
|
|
153
|
+
def redis_now(conn)
|
|
154
|
+
conn.time.first
|
|
155
|
+
rescue Redis::CommandError
|
|
156
|
+
Time.now.to_i
|
|
157
|
+
end
|
|
158
|
+
|
|
136
159
|
# Keeps every in-flight `until_executed` periodic run's
|
|
137
160
|
# `periodic:running:<pjid>` lock alive for as long as this process is —
|
|
138
|
-
# see Periodic::RunningLock.
|
|
139
|
-
#
|
|
140
|
-
#
|
|
141
|
-
def touch_running_periodic_locks(conn,
|
|
142
|
-
|
|
143
|
-
job = JSON.parse(raw)
|
|
161
|
+
# see Periodic::RunningLock. From the Manager's own in-memory record of
|
|
162
|
+
# what's running here, not `cogworker:workers` in Redis: a failover that
|
|
163
|
+
# lost that Hash would otherwise let a long run's lock lapse mid-run.
|
|
164
|
+
def touch_running_periodic_locks(conn, _identity)
|
|
165
|
+
@manager.running_jobs.each do |raw|
|
|
166
|
+
job = JSON.parse(raw)
|
|
144
167
|
next unless job.is_a?(Hash) && job['periodic_pjid']
|
|
145
168
|
|
|
146
169
|
Periodic::RunningLock.touch(job['periodic_pjid'], job['jid'], Periodic::RunningLock.active_ttl, conn)
|
|
@@ -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
|
@@ -37,11 +37,18 @@ module Cogworker
|
|
|
37
37
|
# define `#to_i` (`nil.to_i` happens to be `0` — coincidentally correct
|
|
38
38
|
# for absent — but `false.to_i` raises `NoMethodError` outright, which is
|
|
39
39
|
# exactly the bug this method exists to not have).
|
|
40
|
+
#
|
|
41
|
+
# Anything else (a Hash, a non-numeric String…) means no retries — the
|
|
42
|
+
# cautious reading for a job with side effects — rather than raising
|
|
43
|
+
# from the middle of recording its failure.
|
|
40
44
|
def max_retries(job)
|
|
41
|
-
case job['retry']
|
|
45
|
+
case (value = job['retry'])
|
|
42
46
|
when false then 0
|
|
43
47
|
when true, nil then DEFAULT_MAX_RETRY_ATTEMPTS
|
|
44
|
-
|
|
48
|
+
when Integer then value
|
|
49
|
+
when Float then value.to_i
|
|
50
|
+
when String then Integer(value, exception: false) || 0
|
|
51
|
+
else 0
|
|
45
52
|
end
|
|
46
53
|
end
|
|
47
54
|
|
|
@@ -75,12 +82,17 @@ module Cogworker
|
|
|
75
82
|
# commands, a crash or dropped connection between the ZREM and the
|
|
76
83
|
# LPUSH lost the job. The new payload is built in Ruby, not re-encoded
|
|
77
84
|
# by Lua's cjson (which would round large integers in args).
|
|
85
|
+
# Present? Push, then remove — never remove first: a script stopped by
|
|
86
|
+
# an error keeps the writes made before it, so a ZREM followed by a
|
|
87
|
+
# failing LPUSH (the queue key holding the wrong type) lost the entry.
|
|
88
|
+
# The `cogworker:queues` listing is best-effort.
|
|
78
89
|
CLAIM_AND_REQUEUE_SCRIPT = <<~LUA
|
|
79
|
-
if redis.call("
|
|
90
|
+
if not redis.call("ZSCORE", KEYS[1], ARGV[1]) then
|
|
80
91
|
return 0
|
|
81
92
|
end
|
|
82
|
-
redis.call("SADD", KEYS[2], ARGV[2])
|
|
83
93
|
redis.call("LPUSH", KEYS[3], ARGV[3])
|
|
94
|
+
redis.call("ZREM", KEYS[1], ARGV[1])
|
|
95
|
+
redis.pcall("SADD", KEYS[2], ARGV[2])
|
|
84
96
|
return 1
|
|
85
97
|
LUA
|
|
86
98
|
|
|
@@ -96,12 +108,40 @@ module Cogworker
|
|
|
96
108
|
return :invalid unless job
|
|
97
109
|
|
|
98
110
|
job['enqueued_at'] = Time.now.to_f
|
|
99
|
-
won =
|
|
100
|
-
|
|
101
|
-
|
|
111
|
+
won = LuaScript.run(conn, CLAIM_AND_REQUEUE_SCRIPT,
|
|
112
|
+
keys: [set, RedisKeys::QUEUES, RedisKeys.queue(job['queue'])],
|
|
113
|
+
argv: [raw, job['queue'], JSON.generate(job)])
|
|
102
114
|
won == 1 ? :requeued : :gone
|
|
103
115
|
end
|
|
104
116
|
|
|
117
|
+
ERROR_MESSAGE_LIMIT = 10_000
|
|
118
|
+
|
|
119
|
+
# Valid UTF-8, whatever came in — every string this gem stores from
|
|
120
|
+
# outside its control (an exception's message, a raw payload) goes
|
|
121
|
+
# through here before `JSON.generate`, which raises on invalid UTF-8.
|
|
122
|
+
# An unchecked `raise "bad \xff".b` used to make every attempt to record
|
|
123
|
+
# the failure itself raise, deterministically. Strings in another
|
|
124
|
+
# (valid) encoding are transcoded; binary/broken ones are scrubbed.
|
|
125
|
+
def safe_string(value, limit = nil)
|
|
126
|
+
str = value.to_s
|
|
127
|
+
str = if [Encoding::UTF_8, Encoding::BINARY, Encoding::US_ASCII].include?(str.encoding)
|
|
128
|
+
str.dup.force_encoding(Encoding::UTF_8).scrub
|
|
129
|
+
else
|
|
130
|
+
str.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
|
|
131
|
+
end
|
|
132
|
+
limit ? str[0, limit] : str
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Never raises: an exception whose own `#message` raises (a broken
|
|
136
|
+
# custom exception class) used to escape from the middle of recording
|
|
137
|
+
# its failure — and, the job then counting as not-yet-run, it was handed
|
|
138
|
+
# back and re-run about once a second, forever.
|
|
139
|
+
def error_message(error)
|
|
140
|
+
safe_string(error.message, ERROR_MESSAGE_LIMIT)
|
|
141
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
142
|
+
"#<#{error.class}>"
|
|
143
|
+
end
|
|
144
|
+
|
|
105
145
|
UNPARSEABLE_CLASS = '(unparseable)'
|
|
106
146
|
|
|
107
147
|
# A payload that isn't a JSON object can't be run, retried or even
|
|
@@ -113,8 +153,8 @@ module Cogworker
|
|
|
113
153
|
{
|
|
114
154
|
'class' => UNPARSEABLE_CLASS, 'args' => [], 'queue' => queue, 'jid' => SecureRandom.hex(12),
|
|
115
155
|
'retry' => false, 'retry_count' => 0, 'enqueued_at' => now, 'failed_at' => now,
|
|
116
|
-
'error_class' => error.class.name, 'error_message' => error
|
|
117
|
-
'raw_payload' => raw
|
|
156
|
+
'error_class' => error.class.name, 'error_message' => error_message(error),
|
|
157
|
+
'raw_payload' => safe_string(raw, 100_000)
|
|
118
158
|
}
|
|
119
159
|
end
|
|
120
160
|
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
module Cogworker
|
|
6
|
+
# Finds this gem's Redis keys holding the wrong type of value — which
|
|
7
|
+
# only a manual write or data corruption produces, but which then breaks
|
|
8
|
+
# every command on that key, deterministically: a queue that can't be
|
|
9
|
+
# pushed to, a `dead` set nothing can be filed in, a registry that fails
|
|
10
|
+
# every beat — and moves each such key out of the way, to
|
|
11
|
+
# `cogworker:quarantine:<key>:<time>:<random>` (kept QUARANTINE_TTL for a human to
|
|
12
|
+
# look at), with an error in the log. Everything then carries on with a
|
|
13
|
+
# fresh, empty key of the right type. Run by every process's Scheduled
|
|
14
|
+
# poller at boot and every CHECK_INTERVAL.
|
|
15
|
+
module KeyGuard
|
|
16
|
+
CHECK_INTERVAL = 60
|
|
17
|
+
QUARANTINE_TTL = 30 * 24 * 60 * 60
|
|
18
|
+
QUARANTINE_PREFIX = 'cogworker:quarantine:'
|
|
19
|
+
|
|
20
|
+
FIXED = {
|
|
21
|
+
RedisKeys::SCHEDULE => 'zset', RedisKeys::RETRY => 'zset', RedisKeys::DEAD => 'zset',
|
|
22
|
+
RedisKeys::QUEUES => 'set', RedisKeys::PROCESSES => 'set', RedisKeys::PAUSED_QUEUES => 'set',
|
|
23
|
+
RedisKeys::IN_PROGRESS_IDENTITIES => 'set', RedisKeys::LAST_BEAT => 'hash',
|
|
24
|
+
RedisKeys::REPEAT_ORPHANS => 'list', RedisKeys::UNSETTLED => 'list', RedisKeys::PERIODIC_SCHEDULE => 'hash',
|
|
25
|
+
RedisKeys::PERIODIC_DISABLED => 'set', RedisKeys::STATS_PROCESSED => 'string',
|
|
26
|
+
RedisKeys::STATS_FAILED => 'string'
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
# KEYS[1] = the key, KEYS[2] = its quarantine name; ARGV[1] = expected
|
|
30
|
+
# type, ARGV[2] = quarantine TTL. Re-checked here, atomically with the
|
|
31
|
+
# move, so a key fixed (or recreated) in between is left alone.
|
|
32
|
+
QUARANTINE_SCRIPT = <<~LUA
|
|
33
|
+
local actual = redis.call("TYPE", KEYS[1])["ok"]
|
|
34
|
+
if actual == "none" or actual == ARGV[1] then
|
|
35
|
+
return 0
|
|
36
|
+
end
|
|
37
|
+
redis.call("RENAME", KEYS[1], KEYS[2])
|
|
38
|
+
redis.call("EXPIRE", KEYS[2], ARGV[2])
|
|
39
|
+
return 1
|
|
40
|
+
LUA
|
|
41
|
+
|
|
42
|
+
module_function
|
|
43
|
+
|
|
44
|
+
# Returns the keys quarantined.
|
|
45
|
+
def check(queues: Cogworker.config.queues)
|
|
46
|
+
expected = expected_types(queues)
|
|
47
|
+
actual = Cogworker.config.redis do |c|
|
|
48
|
+
c.pipelined { |p| expected.each_key { |key| p.type(key) } }
|
|
49
|
+
end
|
|
50
|
+
expected.keys.zip(actual).filter_map do |key, type|
|
|
51
|
+
next if type == 'none' || type == expected[key]
|
|
52
|
+
|
|
53
|
+
key if quarantine(key, expected[key], type)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The fixed keys, plus the per-queue and per-process ones currently
|
|
58
|
+
# listed in them (only where the listing itself is readable).
|
|
59
|
+
def expected_types(queues)
|
|
60
|
+
types = FIXED.dup
|
|
61
|
+
queue_names = Array(queues) | readable_members(RedisKeys::QUEUES)
|
|
62
|
+
queue_names.each { |q| types[RedisKeys.queue(q)] = 'list' }
|
|
63
|
+
readable_members(RedisKeys::IN_PROGRESS_IDENTITIES).each { |id| types[RedisKeys.in_progress(id)] = 'list' }
|
|
64
|
+
readable_members(RedisKeys::PROCESSES).each do |id|
|
|
65
|
+
types[RedisKeys.process(id)] = 'hash'
|
|
66
|
+
types[RedisKeys.workers(id)] = 'hash'
|
|
67
|
+
end
|
|
68
|
+
types
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def readable_members(set)
|
|
72
|
+
Cogworker.config.redis { |c| c.smembers(set) }
|
|
73
|
+
rescue Redis::CommandError
|
|
74
|
+
[] # itself of the wrong type: quarantined this round, listed again next
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def quarantine(key, expected, actual)
|
|
78
|
+
# Unique per move: a second quarantine of the same key within the same
|
|
79
|
+
# second must not overwrite (RENAME) the first one's copy.
|
|
80
|
+
target = "#{QUARANTINE_PREFIX}#{key}:#{Time.now.to_i}:#{SecureRandom.hex(4)}"
|
|
81
|
+
moved = Cogworker.config.redis do |c|
|
|
82
|
+
LuaScript.run(c, QUARANTINE_SCRIPT, keys: [key, target], argv: [expected, QUARANTINE_TTL])
|
|
83
|
+
end
|
|
84
|
+
return false unless moved == 1
|
|
85
|
+
|
|
86
|
+
Cogworker.logger.error do
|
|
87
|
+
"#{key} held a #{actual}, not a #{expected}: moved to #{target} (kept #{QUARANTINE_TTL / 86_400} days)"
|
|
88
|
+
end
|
|
89
|
+
true
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
data/lib/cogworker/launcher.rb
CHANGED
|
@@ -13,8 +13,15 @@ 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 that really
|
|
17
|
+
# completed (Scheduled#ready_for_ticker?): after the whole
|
|
18
|
+
# fleet was down longer than a running `until_executed` job's lock
|
|
19
|
+
# lives, the ticker would otherwise claim that entry's next slot before
|
|
20
|
+
# recovery had requeued the interrupted run (and re-taken its lock) —
|
|
21
|
+
# two runs at once.
|
|
16
22
|
@ticker = Periodic::Ticker.new(
|
|
17
|
-
@manager, config.periodic_manager.entries, catch_up: config.periodic_catch_up
|
|
23
|
+
@manager, config.periodic_manager.entries, catch_up: config.periodic_catch_up,
|
|
24
|
+
ready: -> { @scheduled.ready_for_ticker? }
|
|
18
25
|
)
|
|
19
26
|
@signal_queue = ::Queue.new
|
|
20
27
|
end
|
|
@@ -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
|
@@ -7,6 +7,16 @@ module Cogworker
|
|
|
7
7
|
def initialize(config = Cogworker.config)
|
|
8
8
|
@config = config
|
|
9
9
|
@fetch_mutex = Mutex.new
|
|
10
|
+
# This process's own record of its jobs (raw payloads, as fetched):
|
|
11
|
+
# running right now (a count — two copies of one payload can run at
|
|
12
|
+
# once), and finished but not yet settled in Redis — `:ack` (the ack
|
|
13
|
+
# failed) or `[set, score, payload]` (the job ran and failed, and
|
|
14
|
+
# filing it in retry/dead failed too). In memory on purpose:
|
|
15
|
+
# ReliableFetch.reconcile trusts it over anything in Redis, which may
|
|
16
|
+
# be what's failing (or what lost its last writes in a failover).
|
|
17
|
+
@jobs_mutex = Mutex.new
|
|
18
|
+
@running_jobs = Hash.new(0)
|
|
19
|
+
@pending_settlements = {}
|
|
10
20
|
@processors = Array.new(config.concurrency) { Processor.new(self) }
|
|
11
21
|
@quiet = false
|
|
12
22
|
@stopping = false
|
|
@@ -18,11 +28,11 @@ module Cogworker
|
|
|
18
28
|
@config.queues
|
|
19
29
|
end
|
|
20
30
|
|
|
21
|
-
# Resolved on first use
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
31
|
+
# Resolved on first use, not in the constructor (that needed Redis
|
|
32
|
+
# just to build a Manager). First use is normally the Heartbeat's first
|
|
33
|
+
# beat; if Redis can't be reached then, ReliableFetch.supported? raises
|
|
34
|
+
# and nothing is memoized — the beat fails and is retried, and this
|
|
35
|
+
# resolves again next time — rather than memoizing a guess.
|
|
26
36
|
def fetch_class
|
|
27
37
|
@fetch_mutex.synchronize { @fetch_class ||= resolve_fetch_class }
|
|
28
38
|
end
|
|
@@ -63,6 +73,34 @@ module Cogworker
|
|
|
63
73
|
@stopping
|
|
64
74
|
end
|
|
65
75
|
|
|
76
|
+
def job_started(raw)
|
|
77
|
+
@jobs_mutex.synchronize { @running_jobs[raw] += 1 }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def job_finished(raw)
|
|
81
|
+
@jobs_mutex.synchronize do
|
|
82
|
+
@running_jobs[raw] -= 1
|
|
83
|
+
@running_jobs.delete(raw) unless @running_jobs[raw].positive?
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def running_jobs
|
|
88
|
+
@jobs_mutex.synchronize { @running_jobs.keys }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# `how`: :ack, or [set, score, payload] — see ReliableFetch.settle.
|
|
92
|
+
def settle_later(raw, how)
|
|
93
|
+
@jobs_mutex.synchronize { @pending_settlements[raw] = how }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def settled(raw)
|
|
97
|
+
@jobs_mutex.synchronize { @pending_settlements.delete(raw) }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def pending_settlements
|
|
101
|
+
@jobs_mutex.synchronize { @pending_settlements.dup }
|
|
102
|
+
end
|
|
103
|
+
|
|
66
104
|
def busy_count
|
|
67
105
|
@busy_mutex.synchronize { @busy_count }
|
|
68
106
|
end
|
|
@@ -109,8 +147,27 @@ module Cogworker
|
|
|
109
147
|
end
|
|
110
148
|
end
|
|
111
149
|
|
|
150
|
+
# Jobs that finished but aren't settled in Redis yet are still on the
|
|
151
|
+
# in-progress list. One more try at settling them first; then `except:`
|
|
152
|
+
# drops the ones only waiting for their ack (which is what dropping
|
|
153
|
+
# them is), in the same atomic step as requeueing the rest — a separate
|
|
154
|
+
# ack pass that failed used to leave them to be requeued and run again.
|
|
155
|
+
# Ones that couldn't be filed in retry/dead even now are handed over to
|
|
156
|
+
# UNSETTLED for any live process to file later (ReliableFetch.park_unsettled)
|
|
157
|
+
# — never requeued: they already ran. Any that can't even be handed
|
|
158
|
+
# over are left on the list (`keep:`): orphan recovery requeues them
|
|
159
|
+
# once this process is gone, at most `max_orphanings` times.
|
|
112
160
|
def requeue_unfinished
|
|
113
|
-
|
|
161
|
+
keep = []
|
|
162
|
+
if fetch_class == ReliableFetch
|
|
163
|
+
ReliableFetch.settle_pending(Cogworker.identity, pending_settlements) { |raw| settled(raw) }
|
|
164
|
+
unfiled = pending_settlements.reject { |_raw, how| how == :ack }
|
|
165
|
+
keep = ReliableFetch.park_unsettled(Cogworker.identity, unfiled)
|
|
166
|
+
(unfiled.keys - keep).each { |raw| settled(raw) }
|
|
167
|
+
end
|
|
168
|
+
to_ack = pending_settlements.select { |_raw, how| how == :ack }.keys
|
|
169
|
+
moved = fetch_class.requeue_in_progress(Cogworker.identity, except: to_ack, keep: keep)
|
|
170
|
+
to_ack.each { |raw| settled(raw) }
|
|
114
171
|
Cogworker.logger.warn { "requeued #{moved} job(s) unfinished at shutdown" } if moved.positive?
|
|
115
172
|
rescue StandardError => e
|
|
116
173
|
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
|