cogworker 0.4.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 +24 -4
- data/lib/cogworker/heartbeat.rb +11 -1
- data/lib/cogworker/history/storage.rb +2 -2
- data/lib/cogworker/job_util.rb +27 -5
- data/lib/cogworker/launcher.rb +7 -1
- data/lib/cogworker/lua_script.rb +30 -0
- data/lib/cogworker/manager.rb +5 -5
- data/lib/cogworker/owned_key.rb +2 -2
- data/lib/cogworker/periodic/ticker.rb +23 -5
- data/lib/cogworker/processor.rb +140 -56
- data/lib/cogworker/reliable_fetch.rb +164 -73
- data/lib/cogworker/scheduled.rb +14 -4
- 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 +2 -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)
|
|
@@ -75,14 +85,24 @@ module Cogworker
|
|
|
75
85
|
queue_key = RedisKeys.queue(queue)
|
|
76
86
|
retake = job['periodic_until_executed'] && job['periodic_pjid'] && job['jid']
|
|
77
87
|
lock_key = retake ? RedisKeys.periodic_running(job['periodic_pjid']) : queue_key
|
|
78
|
-
|
|
79
|
-
|
|
88
|
+
LuaScript.run(conn, REQUEUE_ENTRY_SCRIPT, keys: [queue_key, lock_key],
|
|
89
|
+
argv: [JSON.generate(job), retake ? '1' : '0', job['jid'].to_s,
|
|
80
90
|
Periodic::RunningLock.queued_ttl])
|
|
81
91
|
end
|
|
82
92
|
private_class_method :requeue_entry
|
|
83
93
|
|
|
84
94
|
private
|
|
85
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
|
+
|
|
86
106
|
def active_keys
|
|
87
107
|
paused = Cogworker.config.redis { |c| c.smembers(RedisKeys::PAUSED_QUEUES) }
|
|
88
108
|
return @queue_keys if paused.empty?
|
data/lib/cogworker/heartbeat.rb
CHANGED
|
@@ -127,12 +127,22 @@ module Cogworker
|
|
|
127
127
|
c.expire(RedisKeys.workers(identity), TTL)
|
|
128
128
|
if reliable
|
|
129
129
|
c.sadd?(RedisKeys::IN_PROGRESS_IDENTITIES, identity)
|
|
130
|
-
c.hset(RedisKeys::LAST_BEAT, identity, c
|
|
130
|
+
c.hset(RedisKeys::LAST_BEAT, identity, redis_now(c))
|
|
131
131
|
end
|
|
132
132
|
touch_running_periodic_locks(c, identity)
|
|
133
133
|
end
|
|
134
134
|
end
|
|
135
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
|
+
|
|
136
146
|
# Keeps every in-flight `until_executed` periodic run's
|
|
137
147
|
# `periodic:running:<pjid>` lock alive for as long as this process is —
|
|
138
148
|
# see Periodic::RunningLock. Reads the same `cogworker:workers:<identity>`
|
|
@@ -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
|
@@ -96,12 +96,34 @@ module Cogworker
|
|
|
96
96
|
return :invalid unless job
|
|
97
97
|
|
|
98
98
|
job['enqueued_at'] = Time.now.to_f
|
|
99
|
-
won =
|
|
100
|
-
|
|
101
|
-
|
|
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
102
|
won == 1 ? :requeued : :gone
|
|
103
103
|
end
|
|
104
104
|
|
|
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)
|
|
125
|
+
end
|
|
126
|
+
|
|
105
127
|
UNPARSEABLE_CLASS = '(unparseable)'
|
|
106
128
|
|
|
107
129
|
# A payload that isn't a JSON object can't be run, retried or even
|
|
@@ -113,8 +135,8 @@ module Cogworker
|
|
|
113
135
|
{
|
|
114
136
|
'class' => UNPARSEABLE_CLASS, 'args' => [], 'queue' => queue, 'jid' => SecureRandom.hex(12),
|
|
115
137
|
'retry' => false, 'retry_count' => 0, 'enqueued_at' => now, 'failed_at' => now,
|
|
116
|
-
'error_class' => error.class.name, 'error_message' => error
|
|
117
|
-
'raw_payload' => raw
|
|
138
|
+
'error_class' => error.class.name, 'error_message' => error_message(error),
|
|
139
|
+
'raw_payload' => safe_string(raw, 100_000)
|
|
118
140
|
}
|
|
119
141
|
end
|
|
120
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
|
|
@@ -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
|
@@ -18,11 +18,11 @@ module Cogworker
|
|
|
18
18
|
@config.queues
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
-
# Resolved on first use
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
-
#
|
|
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
26
|
def fetch_class
|
|
27
27
|
@fetch_mutex.synchronize { @fetch_class ||= resolve_fetch_class }
|
|
28
28
|
end
|
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
|
|
@@ -36,12 +36,14 @@ module Cogworker
|
|
|
36
36
|
return 1
|
|
37
37
|
LUA
|
|
38
38
|
|
|
39
|
-
def initialize(manager, entries, catch_up: true)
|
|
39
|
+
def initialize(manager, entries, catch_up: true, ready: -> { true })
|
|
40
40
|
@manager = manager
|
|
41
41
|
@entries = entries
|
|
42
42
|
@catch_up = catch_up
|
|
43
|
+
@ready = ready
|
|
43
44
|
@last_checked_slot = {}
|
|
44
45
|
@cron_cache = {}
|
|
46
|
+
@pending_rollbacks = []
|
|
45
47
|
end
|
|
46
48
|
|
|
47
49
|
def start!
|
|
@@ -82,7 +84,7 @@ module Cogworker
|
|
|
82
84
|
def run
|
|
83
85
|
until @manager.stopping?
|
|
84
86
|
begin
|
|
85
|
-
tick unless @manager.quiet?
|
|
87
|
+
tick unless @manager.quiet? || !@ready.call
|
|
86
88
|
rescue StandardError => e
|
|
87
89
|
Cogworker.logger.error { "Periodic tick failed: #{e.class}: #{e.message}" }
|
|
88
90
|
end
|
|
@@ -91,6 +93,7 @@ module Cogworker
|
|
|
91
93
|
end
|
|
92
94
|
|
|
93
95
|
def tick
|
|
96
|
+
retry_pending_rollbacks
|
|
94
97
|
now = Time.now
|
|
95
98
|
@entries.each do |entry|
|
|
96
99
|
slot = cron_for(entry).previous_time(now).to_i
|
|
@@ -114,13 +117,28 @@ module Cogworker
|
|
|
114
117
|
# persisted last_slot) still advances either way, exactly as it
|
|
115
118
|
# already did before this entry ever had a disabled state — it only
|
|
116
119
|
# stops this same tick loop from re-evaluating the same slot twice.
|
|
120
|
+
# The usual reason a push fails — Redis unreachable — makes this fail
|
|
121
|
+
# too; it's then kept and retried at the start of every tick until it
|
|
122
|
+
# goes through (before that tick looks at the slot again), rather than
|
|
123
|
+
# just logged and the slot lost. (A push that timed out *after* Redis
|
|
124
|
+
# had applied it is the one case this turns into a second run of the
|
|
125
|
+
# slot: indistinguishable from one that never arrived.)
|
|
117
126
|
def rollback_claim(entry, slot, previous_slot)
|
|
118
127
|
Cogworker.config.redis do |c|
|
|
119
128
|
keys = [RedisKeys.periodic_last_slot(entry.pjid), RedisKeys.periodic_lock(entry.pjid, slot)]
|
|
120
|
-
|
|
129
|
+
LuaScript.run(c, ROLLBACK_SCRIPT, keys: keys, argv: [slot, previous_slot])
|
|
121
130
|
end
|
|
131
|
+
true
|
|
122
132
|
rescue StandardError => e
|
|
123
|
-
Cogworker.logger.error { "periodic #{entry.pjid}: couldn't roll back slot #{slot}: #{e.class}: #{e.message}" }
|
|
133
|
+
Cogworker.logger.error { "periodic #{entry.pjid}: couldn't roll back slot #{slot} (will retry): #{e.class}: #{e.message}" }
|
|
134
|
+
@pending_rollbacks << [entry, slot, previous_slot]
|
|
135
|
+
false
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def retry_pending_rollbacks
|
|
139
|
+
pending = @pending_rollbacks
|
|
140
|
+
@pending_rollbacks = []
|
|
141
|
+
pending.each { |args| rollback_claim(*args) }
|
|
124
142
|
end
|
|
125
143
|
|
|
126
144
|
def disabled?(entry)
|
|
@@ -137,7 +155,7 @@ module Cogworker
|
|
|
137
155
|
return nil if !@catch_up && priming_first_slot?(entry, slot)
|
|
138
156
|
|
|
139
157
|
result = Cogworker.config.redis do |c|
|
|
140
|
-
|
|
158
|
+
LuaScript.run(c, CLAIM_SCRIPT,
|
|
141
159
|
keys: [RedisKeys.periodic_running(entry.pjid), RedisKeys.periodic_last_slot(entry.pjid),
|
|
142
160
|
RedisKeys.periodic_lock(entry.pjid, slot)],
|
|
143
161
|
argv: [slot, entry.unique.to_s, LOCK_TTL, jid, RunningLock.queued_ttl])
|
data/lib/cogworker/processor.rb
CHANGED
|
@@ -6,9 +6,26 @@ require 'securerandom'
|
|
|
6
6
|
module Cogworker
|
|
7
7
|
# One thread of a Manager's pool: fetch -> register in-flight -> run the
|
|
8
8
|
# server middleware chain around perform -> stats/retry -> deregister.
|
|
9
|
+
#
|
|
10
|
+
# What happens to the fetched job afterwards depends on how far it got —
|
|
11
|
+
# the one rule being that a job is never re-run because of a failure that
|
|
12
|
+
# happened *after* it ran:
|
|
13
|
+
# - failed before `perform` (registering it in `cogworker:workers`):
|
|
14
|
+
# `give_back` — straight back on its queue, it hasn't run yet;
|
|
15
|
+
# - ran (succeeded, or failed and was recorded in retry/dead): acknowledge.
|
|
16
|
+
# Everything after `perform` other than that one retry/dead write —
|
|
17
|
+
# stats, throughput, deregistering, lock extension, the attempts log —
|
|
18
|
+
# is best-effort: logged, never raised;
|
|
19
|
+
# - ran and failed, but the retry/dead write itself failed: `interrupt` —
|
|
20
|
+
# into `retry` after INTERRUPT_DELAY, counting `interrupted_count`, and
|
|
21
|
+
# into `dead` once that passes MAX_INTERRUPTS. Never straight back on
|
|
22
|
+
# the queue: a write that fails deterministically used to make that
|
|
23
|
+
# loop forever, re-running the job every second.
|
|
9
24
|
class Processor
|
|
10
25
|
ERROR_BACKOFF = 1 # seconds
|
|
11
26
|
ACK_ATTEMPTS = 3
|
|
27
|
+
MAX_INTERRUPTS = 3
|
|
28
|
+
INTERRUPT_DELAY = 30 # seconds, times the interruption count
|
|
12
29
|
|
|
13
30
|
attr_reader :thread, :tid
|
|
14
31
|
|
|
@@ -70,29 +87,58 @@ module Cogworker
|
|
|
70
87
|
|
|
71
88
|
@manager.processor_busy!
|
|
72
89
|
@busy = true
|
|
73
|
-
|
|
90
|
+
disposition = :give_back
|
|
74
91
|
begin
|
|
75
|
-
execute(work)
|
|
76
|
-
finished = true
|
|
92
|
+
disposition = execute(work)
|
|
77
93
|
ensure
|
|
78
94
|
@busy = false
|
|
79
95
|
@manager.processor_idle!
|
|
80
|
-
|
|
96
|
+
settle(work, disposition)
|
|
81
97
|
end
|
|
82
98
|
end
|
|
83
99
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
100
|
+
def settle(work, disposition)
|
|
101
|
+
case disposition
|
|
102
|
+
when :acknowledge then acknowledge(work)
|
|
103
|
+
when :give_back then give_back(work)
|
|
104
|
+
else interrupt(work, *disposition.drop(1))
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Only for a job that hasn't run yet (see the class comment). If even
|
|
109
|
+
# this fails, it stays on the in-progress list, which `Manager#stop!` /
|
|
110
|
+
# orphan recovery / `ReliableFetch.reconcile` requeue later.
|
|
91
111
|
def give_back(work)
|
|
92
112
|
fetcher.give_back(work)
|
|
93
113
|
rescue StandardError => e
|
|
94
114
|
Cogworker.logger.error do
|
|
95
|
-
"couldn't hand back jid=#{jid_of(work)}
|
|
115
|
+
"couldn't hand back jid=#{jid_of(work)} (left in progress): #{e.class}: #{e.message}"
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# The job ran and failed, but recording that in retry/dead failed. Built
|
|
120
|
+
# from the original payload (always serializable — it came from JSON)
|
|
121
|
+
# plus the failure fields, never from the in-memory job hash: whatever
|
|
122
|
+
# made that one fail to serialize must not do it again here.
|
|
123
|
+
def interrupt(work, job, error)
|
|
124
|
+
payload = JSON.parse(work.raw_job)
|
|
125
|
+
count = payload['interrupted_count'].to_i + 1
|
|
126
|
+
payload.merge!('interrupted_count' => count, 'retry_count' => job['retry_count'],
|
|
127
|
+
'error_class' => error.class.name, 'error_message' => JobUtil.error_message(error),
|
|
128
|
+
'failed_at' => Time.now.to_f)
|
|
129
|
+
set, score = if count > MAX_INTERRUPTS
|
|
130
|
+
[RedisKeys::DEAD, Time.now.to_f]
|
|
131
|
+
else
|
|
132
|
+
[RedisKeys::RETRY, Time.now.to_f + (INTERRUPT_DELAY * count)]
|
|
133
|
+
end
|
|
134
|
+
fetcher.interrupt(work, set, score, JSON.generate(payload))
|
|
135
|
+
Cogworker.logger.error do
|
|
136
|
+
"couldn't record the failure of jid=#{payload['jid']} (#{error.class}); " \
|
|
137
|
+
"moved to #{set.delete_prefix('cogworker:')} (interruption #{count}/#{MAX_INTERRUPTS})"
|
|
138
|
+
end
|
|
139
|
+
rescue StandardError => e
|
|
140
|
+
Cogworker.logger.error do
|
|
141
|
+
"couldn't record or interrupt jid=#{jid_of(work)} (left in progress): #{e.class}: #{e.message}"
|
|
96
142
|
end
|
|
97
143
|
end
|
|
98
144
|
|
|
@@ -123,53 +169,80 @@ module Cogworker
|
|
|
123
169
|
'?'
|
|
124
170
|
end
|
|
125
171
|
|
|
172
|
+
# Returns how `settle` should dispose of the fetched job; raises only
|
|
173
|
+
# before the job has run (then `process_one`'s default, :give_back,
|
|
174
|
+
# applies).
|
|
126
175
|
def execute(work)
|
|
127
176
|
job = parse_job(work)
|
|
128
|
-
return unless job
|
|
177
|
+
return :acknowledge unless job
|
|
129
178
|
|
|
130
179
|
register_in_workers(work.queue, job)
|
|
131
180
|
Cogworker.logger.info { "start: #{job['class']} jid=#{job['jid']}" }
|
|
132
|
-
|
|
133
181
|
begin
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
# resolution failure caught out here, before `invoke` ever runs,
|
|
141
|
-
# would never reach them — this job would fail/retry/die with no
|
|
142
|
-
# History entry and no status update at all, silently. Catching it
|
|
143
|
-
# here and re-raising it as the chain's own "final block" gives
|
|
144
|
-
# every middleware the same crack at observing this failure as any
|
|
145
|
-
# perform-time one, with `worker` as `nil` in that case (every
|
|
146
|
-
# built-in server middleware ignores the `worker` arg entirely
|
|
147
|
-
# already; a custom one that needs a real instance simply can't act
|
|
148
|
-
# on this failure either way — there's no worker to act on).
|
|
149
|
-
resolution_error = nil
|
|
150
|
-
worker = begin
|
|
151
|
-
build_worker(job)
|
|
152
|
-
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
153
|
-
resolution_error = e
|
|
154
|
-
nil
|
|
155
|
-
end
|
|
182
|
+
error = run_job(work, job)
|
|
183
|
+
error ? finish_failure(job, error) : finish_success(job)
|
|
184
|
+
ensure
|
|
185
|
+
best_effort('deregister') { deregister_from_workers }
|
|
186
|
+
end
|
|
187
|
+
end
|
|
156
188
|
|
|
157
|
-
|
|
158
|
-
|
|
189
|
+
# The job's own outcome: nil, or what it (or its middleware) raised.
|
|
190
|
+
def run_job(work, job)
|
|
191
|
+
# `build_worker` itself can raise (most commonly `NameError`, e.g. a
|
|
192
|
+
# class the caller registered/enqueued but never actually defined —
|
|
193
|
+
# deliberately still routed through the *same* middleware chain
|
|
194
|
+
# below rather than caught here directly: `History::Middleware`/
|
|
195
|
+
# `Status::ServerMiddleware` (and any custom server middleware)
|
|
196
|
+
# only ever see a job by wrapping this `chain.invoke` call, so a
|
|
197
|
+
# resolution failure caught out here, before `invoke` ever runs,
|
|
198
|
+
# would never reach them — this job would fail/retry/die with no
|
|
199
|
+
# History entry and no status update at all, silently. Catching it
|
|
200
|
+
# here and re-raising it as the chain's own "final block" gives
|
|
201
|
+
# every middleware the same crack at observing this failure as any
|
|
202
|
+
# perform-time one, with `worker` as `nil` in that case (every
|
|
203
|
+
# built-in server middleware ignores the `worker` arg entirely
|
|
204
|
+
# already; a custom one that needs a real instance simply can't act
|
|
205
|
+
# on this failure either way — there's no worker to act on).
|
|
206
|
+
resolution_error = nil
|
|
207
|
+
worker = begin
|
|
208
|
+
build_worker(job)
|
|
209
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
210
|
+
resolution_error = e
|
|
211
|
+
nil
|
|
212
|
+
end
|
|
159
213
|
|
|
160
|
-
|
|
161
|
-
|
|
214
|
+
Cogworker.config.server_chain.invoke(worker, job, work.queue) do
|
|
215
|
+
raise resolution_error if resolution_error
|
|
216
|
+
|
|
217
|
+
worker.perform(*job['args'])
|
|
218
|
+
end
|
|
219
|
+
nil
|
|
220
|
+
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
221
|
+
e
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def finish_success(job)
|
|
225
|
+
best_effort('stats') do
|
|
162
226
|
Cogworker.config.redis { |c| c.incr(RedisKeys::STATS_PROCESSED) }
|
|
163
227
|
Throughput.record('processed')
|
|
164
|
-
|
|
165
|
-
|
|
228
|
+
end
|
|
229
|
+
Cogworker.logger.info { "done: #{job['class']} jid=#{job['jid']}" }
|
|
230
|
+
:acknowledge
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def finish_failure(job, error)
|
|
234
|
+
best_effort('stats') do
|
|
166
235
|
Cogworker.config.redis { |c| c.incr(RedisKeys::STATS_FAILED) }
|
|
167
236
|
Throughput.record('failed')
|
|
168
|
-
route_failure(job, e)
|
|
169
|
-
Cogworker.logger.warn { "fail: #{job['class']} jid=#{job['jid']}: #{e.class}: #{e&.message}" }
|
|
170
237
|
end
|
|
171
|
-
|
|
172
|
-
|
|
238
|
+
Cogworker.logger.warn { "fail: #{job['class']} jid=#{job['jid']}: #{error.class}: #{JobUtil.error_message(error)}" }
|
|
239
|
+
route_failure(job, error) ? :acknowledge : [:interrupt, job, error]
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def best_effort(what)
|
|
243
|
+
yield
|
|
244
|
+
rescue StandardError => e
|
|
245
|
+
Cogworker.logger.error { "#{what} failed (ignored): #{e.class}: #{e.message}" }
|
|
173
246
|
end
|
|
174
247
|
|
|
175
248
|
# A payload that isn't a JSON object goes straight to `dead`
|
|
@@ -199,7 +272,7 @@ module Cogworker
|
|
|
199
272
|
# Built on first use, not in the constructor (see Manager#fetch_class),
|
|
200
273
|
# and rebuilt after a fall-back to BasicFetch.
|
|
201
274
|
def fetcher
|
|
202
|
-
@fetcher ||= @manager.fetch_class.new(@manager.queues)
|
|
275
|
+
@fetcher ||= @manager.fetch_class.new(@manager.queues, stopping: -> { @manager.stopping? })
|
|
203
276
|
end
|
|
204
277
|
|
|
205
278
|
def build_worker(job)
|
|
@@ -209,24 +282,35 @@ module Cogworker
|
|
|
209
282
|
worker
|
|
210
283
|
end
|
|
211
284
|
|
|
285
|
+
# True once the failure is recorded in retry/dead — the one write that
|
|
286
|
+
# must happen; everything after it is best-effort. False if that write
|
|
287
|
+
# failed (`finish_failure` then interrupts the job instead).
|
|
212
288
|
def route_failure(job, error)
|
|
213
289
|
job['error_class'] = error.class.name
|
|
214
|
-
job['error_message'] = error
|
|
290
|
+
job['error_message'] = JobUtil.error_message(error)
|
|
215
291
|
job['failed_at'] ||= Time.now.to_f
|
|
216
292
|
|
|
217
293
|
max_retries = JobUtil.max_retries(job)
|
|
218
294
|
new_count = job['retry_count'].to_i + 1
|
|
219
295
|
job['retry_count'] = new_count
|
|
296
|
+
retrying = new_count <= max_retries
|
|
297
|
+
delay = retry_delay(new_count) if retrying
|
|
298
|
+
|
|
299
|
+
begin
|
|
300
|
+
payload = JSON.generate(job)
|
|
301
|
+
Cogworker.config.redis do |c|
|
|
302
|
+
retrying ? c.zadd(RedisKeys::RETRY, Time.now.to_f + delay, payload) : c.zadd(RedisKeys::DEAD, Time.now.to_f, payload)
|
|
303
|
+
end
|
|
304
|
+
rescue StandardError => e
|
|
305
|
+
Cogworker.logger.error { "couldn't record failure of jid=#{job['jid']}: #{e.class}: #{e.message}" }
|
|
306
|
+
return false
|
|
307
|
+
end
|
|
220
308
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
extend_locks_for_retry(job, delay)
|
|
225
|
-
Attempts.record(job['jid'], attempt: new_count, error: error, outcome: 'retrying')
|
|
226
|
-
else
|
|
227
|
-
Cogworker.config.redis { |c| c.zadd(RedisKeys::DEAD, Time.now.to_f, JSON.generate(job)) }
|
|
228
|
-
Attempts.record(job['jid'], attempt: new_count, error: error, outcome: 'dead')
|
|
309
|
+
best_effort('lock extension') { extend_locks_for_retry(job, delay) } if retrying
|
|
310
|
+
best_effort('attempts log') do
|
|
311
|
+
Attempts.record(job['jid'], attempt: new_count, error: error, outcome: retrying ? 'retrying' : 'dead')
|
|
229
312
|
end
|
|
313
|
+
true
|
|
230
314
|
end
|
|
231
315
|
|
|
232
316
|
# A job waiting out its retry backoff still holds its `until_executed`
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'digest/sha1'
|
|
4
|
+
require 'json'
|
|
4
5
|
|
|
5
6
|
module Cogworker
|
|
6
7
|
# The default fetch (`config.fetch = :reliable`): every job is moved
|
|
@@ -41,109 +42,181 @@ module Cogworker
|
|
|
41
42
|
return false
|
|
42
43
|
LUA
|
|
43
44
|
|
|
44
|
-
#
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
# the next slot alongside the recovered run.
|
|
58
|
-
REQUEUE_SCRIPT = Periodic::RunningLock::RETAKE_FUNCTION + <<~LUA
|
|
59
|
-
local moved = 0
|
|
60
|
-
while true do
|
|
61
|
-
local job = redis.call("LPOP", KEYS[1])
|
|
62
|
-
if not job then
|
|
63
|
-
return moved
|
|
64
|
-
end
|
|
65
|
-
local queue = ARGV[2]
|
|
45
|
+
# (Run by hash, like every script here — see LuaScript.)
|
|
46
|
+
FETCH_SCRIPT_SHA = LuaScript.sha(FETCH_SCRIPT)
|
|
47
|
+
|
|
48
|
+
# Lua shared by every script here that puts a job back on its queue:
|
|
49
|
+
# onto the end its queue is popped from (so it runs next), onto
|
|
50
|
+
# `default_queue` if its own `queue` can't be read (the Processor then
|
|
51
|
+
# buries it as unparseable) — and a job from an `until_executed`
|
|
52
|
+
# periodic entry re-takes its running lock on the way
|
|
53
|
+
# (Periodic::RunningLock::RETAKE_FUNCTION), so the ticker can't start
|
|
54
|
+
# the next slot alongside the requeued run.
|
|
55
|
+
REQUEUE_JOB_FUNCTION = Periodic::RunningLock::RETAKE_FUNCTION + <<~LUA
|
|
56
|
+
local function requeue_job(job, queue_prefix, default_queue, lock_prefix, lock_ttl)
|
|
57
|
+
local queue = default_queue
|
|
66
58
|
local ok, decoded = pcall(cjson.decode, job)
|
|
67
59
|
if ok and type(decoded) == "table" then
|
|
68
|
-
if type(decoded["queue"]) == "string" then
|
|
60
|
+
if type(decoded["queue"]) == "string" and decoded["queue"] ~= "" then
|
|
69
61
|
queue = decoded["queue"]
|
|
70
62
|
end
|
|
71
63
|
local pjid, jid = decoded["periodic_pjid"], decoded["jid"]
|
|
72
64
|
if decoded["periodic_until_executed"] == true and type(pjid) == "string" and type(jid) == "string" then
|
|
73
|
-
retake_running_lock(
|
|
65
|
+
retake_running_lock(lock_prefix .. pjid, jid, lock_ttl)
|
|
74
66
|
end
|
|
75
67
|
end
|
|
76
|
-
redis.call("RPUSH",
|
|
68
|
+
redis.call("RPUSH", queue_prefix .. queue, job)
|
|
69
|
+
end
|
|
70
|
+
LUA
|
|
71
|
+
|
|
72
|
+
# KEYS[1] = an in-progress list, KEYS[2] = its owner's presence key,
|
|
73
|
+
# KEYS[3] = LAST_BEAT; ARGV[1..4] = requeue_job's prefix/default
|
|
74
|
+
# queue/lock prefix/lock TTL, ARGV[5] = owner identity, ARGV[6] = orphan
|
|
75
|
+
# threshold, or "" to requeue unconditionally (this process's own list,
|
|
76
|
+
# on shutdown). Returns how many it moved, or -1 if the owner turned
|
|
77
|
+
# out to be alive. Oldest first, so recovered jobs keep their order. The
|
|
78
|
+
# "is the owner really dead?" check (see ReliableFetch.dead?) is made
|
|
79
|
+
# here, atomically with the requeue: checked separately, an owner that
|
|
80
|
+
# beat in between would lose jobs it is still running to a second run.
|
|
81
|
+
# Atomic per call, so two processes recovering one list can't both move
|
|
82
|
+
# a job.
|
|
83
|
+
REQUEUE_SCRIPT = REQUEUE_JOB_FUNCTION + <<~LUA
|
|
84
|
+
if ARGV[6] ~= "" then
|
|
85
|
+
if redis.call("EXISTS", KEYS[2]) == 1 then
|
|
86
|
+
return -1
|
|
87
|
+
end
|
|
88
|
+
local last = redis.call("HGET", KEYS[3], ARGV[5])
|
|
89
|
+
if last then
|
|
90
|
+
if redis.replicate_commands then redis.replicate_commands() end
|
|
91
|
+
local now = tonumber(redis.call("TIME")[1])
|
|
92
|
+
if now - tonumber(last) <= tonumber(ARGV[6]) then
|
|
93
|
+
return -1
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
local moved = 0
|
|
98
|
+
while true do
|
|
99
|
+
local job = redis.call("LPOP", KEYS[1])
|
|
100
|
+
if not job then
|
|
101
|
+
return moved
|
|
102
|
+
end
|
|
103
|
+
requeue_job(job, ARGV[1], ARGV[2], ARGV[3], ARGV[4])
|
|
77
104
|
moved = moved + 1
|
|
78
105
|
end
|
|
79
106
|
LUA
|
|
80
107
|
|
|
81
|
-
# KEYS[1] = in-progress list
|
|
82
|
-
|
|
108
|
+
# KEYS[1] = an in-progress list; ARGV[1] = one job on it, ARGV[2..5] =
|
|
109
|
+
# requeue_job's arguments. That one job, if still on the list, back on
|
|
110
|
+
# its queue: `give_back`, and `reconcile`'s strays.
|
|
111
|
+
REQUEUE_ONE_SCRIPT = REQUEUE_JOB_FUNCTION + <<~LUA
|
|
83
112
|
if redis.call("LREM", KEYS[1], 1, ARGV[1]) == 1 then
|
|
84
|
-
|
|
113
|
+
requeue_job(ARGV[1], ARGV[2], ARGV[3], ARGV[4], ARGV[5])
|
|
114
|
+
return 1
|
|
85
115
|
end
|
|
116
|
+
return 0
|
|
117
|
+
LUA
|
|
118
|
+
|
|
119
|
+
# KEYS[1] = in-progress list, KEYS[2] = retry/dead ZSET; ARGV[1] = the
|
|
120
|
+
# job as fetched, ARGV[2] = score, ARGV[3] = payload to file. Off the
|
|
121
|
+
# in-progress list and into the ZSET in one step; a no-op if it's no
|
|
122
|
+
# longer in progress (already requeued by a shutdown).
|
|
123
|
+
INTERRUPT_SCRIPT = <<~LUA
|
|
124
|
+
if redis.call("LREM", KEYS[1], 1, ARGV[1]) == 1 then
|
|
125
|
+
redis.call("ZADD", KEYS[2], ARGV[2], ARGV[3])
|
|
126
|
+
return 1
|
|
127
|
+
end
|
|
128
|
+
return 0
|
|
86
129
|
LUA
|
|
87
130
|
|
|
88
131
|
class << self
|
|
132
|
+
# A connection error propagates: can't tell yet, so the caller
|
|
133
|
+
# (Manager#fetch_class) resolves again on its next use, instead of
|
|
134
|
+
# memoizing a guess made while Redis was unreachable. Anything else
|
|
135
|
+
# (INFO restricted, an odd version string) assumes a current server —
|
|
136
|
+
# the runtime fall-back (UnsupportedError) still catches a wrong guess.
|
|
89
137
|
def supported?
|
|
90
138
|
version = Cogworker.config.redis { |c| c.info('server')['redis_version'] }
|
|
91
139
|
Gem::Version.new(version) >= MIN_REDIS_VERSION
|
|
140
|
+
rescue Redis::BaseConnectionError
|
|
141
|
+
raise
|
|
92
142
|
rescue StandardError
|
|
93
|
-
true
|
|
143
|
+
true
|
|
94
144
|
end
|
|
95
145
|
|
|
96
146
|
# Puts every job still on `identity`'s in-progress list back on its
|
|
97
|
-
# queue; returns how many
|
|
98
|
-
#
|
|
99
|
-
|
|
147
|
+
# queue; returns how many — or -1 if `if_dead_for:` is given and the
|
|
148
|
+
# owner turns out not to be dead (see REQUEUE_SCRIPT). Used without
|
|
149
|
+
# it for this process's own unfinished jobs on shutdown
|
|
150
|
+
# (`Manager#stop!`), with it for presumed-dead processes' lists.
|
|
151
|
+
def requeue_in_progress(identity, if_dead_for: nil)
|
|
100
152
|
Cogworker.config.redis do |c|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
153
|
+
LuaScript.run(c, REQUEUE_SCRIPT,
|
|
154
|
+
keys: [RedisKeys.in_progress(identity), RedisKeys.process(identity), RedisKeys::LAST_BEAT],
|
|
155
|
+
argv: [*requeue_args, identity, if_dead_for.to_s])
|
|
104
156
|
end
|
|
105
157
|
end
|
|
106
158
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
#
|
|
112
|
-
#
|
|
113
|
-
#
|
|
114
|
-
#
|
|
115
|
-
#
|
|
159
|
+
def requeue_args
|
|
160
|
+
[RedisKeys::QUEUE_PREFIX, 'default', RedisKeys.periodic_running(''), Periodic::RunningLock.queued_ttl]
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Requeues the in-progress list of every process that has died without
|
|
164
|
+
# a clean shutdown. "Dead" means its presence key is gone (no beat for
|
|
165
|
+
# Heartbeat::TTL) *and* `config.orphan_threshold` has passed since its
|
|
166
|
+
# last recorded beat (LAST_BEAT, on Redis's clock) — the presence key
|
|
167
|
+
# alone expires after a minute, too eagerly for a live process that
|
|
168
|
+
# merely couldn't beat for a while (a Redis outage or failover, a long
|
|
169
|
+
# GVL-holding call), whose running jobs would then run twice. With no
|
|
170
|
+
# recorded beat at all (lists from before LAST_BEAT existed), the
|
|
171
|
+
# presence key alone decides. Checked inside REQUEUE_SCRIPT, atomically
|
|
172
|
+
# with the requeue. A process always beats before its first fetch
|
|
173
|
+
# (`Heartbeat#start!`). Walks IN_PROGRESS_IDENTITIES (each
|
|
174
|
+
# reliable-fetch process registers itself there on every beat) rather
|
|
175
|
+
# than SCANning the keyspace; `scan: true` (once per reliable process,
|
|
176
|
+
# at boot) also picks up lists from before that set existed.
|
|
116
177
|
def recover_orphans(scan: false)
|
|
117
178
|
own = Cogworker.identity
|
|
118
179
|
candidate_identities(scan: scan).sum do |identity|
|
|
119
|
-
next 0 if identity == own
|
|
180
|
+
next 0 if identity == own
|
|
120
181
|
|
|
121
|
-
requeue_in_progress(identity
|
|
122
|
-
|
|
123
|
-
next unless moved.positive?
|
|
182
|
+
moved = requeue_in_progress(identity, if_dead_for: Cogworker.config.orphan_threshold)
|
|
183
|
+
next 0 if moved.negative? # alive after all: keep its registration and last beat
|
|
124
184
|
|
|
125
|
-
|
|
126
|
-
|
|
185
|
+
forget_if_empty(identity)
|
|
186
|
+
Cogworker.logger.warn { "requeued #{moved} job(s) left in progress by dead process #{identity}" } if moved.positive?
|
|
187
|
+
moved
|
|
127
188
|
end
|
|
128
189
|
end
|
|
129
190
|
|
|
130
|
-
#
|
|
131
|
-
#
|
|
132
|
-
#
|
|
133
|
-
#
|
|
134
|
-
#
|
|
135
|
-
#
|
|
136
|
-
#
|
|
137
|
-
|
|
138
|
-
def dead?(identity)
|
|
191
|
+
# This process's own in-progress entries that no processor thread is
|
|
192
|
+
# running (not in `cogworker:workers:<identity>`) — left behind when
|
|
193
|
+
# neither acknowledging nor handing a job back worked. Requeued once
|
|
194
|
+
# they've been seen stray on two calls in a row (the caller passes
|
|
195
|
+
# back what the previous call returned), which rules out a job caught
|
|
196
|
+
# in the instant between being fetched and registered. Returns this
|
|
197
|
+
# call's strays that weren't requeued yet.
|
|
198
|
+
def reconcile(identity, previous_strays = [])
|
|
139
199
|
Cogworker.config.redis do |c|
|
|
140
|
-
|
|
200
|
+
listed = c.lrange(RedisKeys.in_progress(identity), 0, -1)
|
|
201
|
+
running = c.hvals(RedisKeys.workers(identity)).filter_map { |raw| parse_hash(raw)&.dig('payload', 'jid') }
|
|
202
|
+
strays = listed.reject { |raw| running.include?(parse_hash(raw)&.fetch('jid', nil)) }
|
|
203
|
+
(strays & previous_strays).each do |raw|
|
|
204
|
+
next unless LuaScript.run(c, REQUEUE_ONE_SCRIPT, keys: [RedisKeys.in_progress(identity)],
|
|
205
|
+
argv: [raw, *requeue_args]) == 1
|
|
141
206
|
|
|
142
|
-
|
|
143
|
-
|
|
207
|
+
Cogworker.logger.warn { "requeued stray in-progress job jid=#{parse_hash(raw)&.fetch('jid', nil)}" }
|
|
208
|
+
end
|
|
209
|
+
strays - previous_strays
|
|
144
210
|
end
|
|
145
211
|
end
|
|
146
212
|
|
|
213
|
+
def parse_hash(raw)
|
|
214
|
+
value = JSON.parse(raw)
|
|
215
|
+
value.is_a?(Hash) ? value : nil
|
|
216
|
+
rescue JSON::ParserError
|
|
217
|
+
nil
|
|
218
|
+
end
|
|
219
|
+
|
|
147
220
|
def candidate_identities(scan:)
|
|
148
221
|
Cogworker.config.redis do |c|
|
|
149
222
|
identities = c.smembers(RedisKeys::IN_PROGRESS_IDENTITIES)
|
|
@@ -169,10 +242,13 @@ module Cogworker
|
|
|
169
242
|
def retrieve_work
|
|
170
243
|
keys = active_keys
|
|
171
244
|
if keys.empty?
|
|
172
|
-
|
|
245
|
+
interruptible_sleep(TIMEOUT)
|
|
173
246
|
return nil
|
|
174
247
|
end
|
|
175
248
|
|
|
249
|
+
distinct = keys.uniq
|
|
250
|
+
return blocking_fetch(distinct.first) if distinct.size == 1
|
|
251
|
+
|
|
176
252
|
result = run_fetch_script(keys.shuffle.uniq + [RedisKeys.in_progress(Cogworker.identity)])
|
|
177
253
|
unless result
|
|
178
254
|
idle_sleep
|
|
@@ -189,8 +265,15 @@ module Cogworker
|
|
|
189
265
|
# no longer in progress (already requeued by `requeue_in_progress`).
|
|
190
266
|
def give_back(work)
|
|
191
267
|
Cogworker.config.redis do |c|
|
|
192
|
-
|
|
193
|
-
|
|
268
|
+
LuaScript.run(c, REQUEUE_ONE_SCRIPT, keys: [RedisKeys.in_progress(Cogworker.identity)],
|
|
269
|
+
argv: [work.raw_job, *self.class.requeue_args])
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def interrupt(work, set, score, payload)
|
|
274
|
+
Cogworker.config.redis do |c|
|
|
275
|
+
LuaScript.run(c, INTERRUPT_SCRIPT, keys: [RedisKeys.in_progress(Cogworker.identity), set],
|
|
276
|
+
argv: [work.raw_job, score, payload])
|
|
194
277
|
end
|
|
195
278
|
end
|
|
196
279
|
|
|
@@ -200,14 +283,22 @@ module Cogworker
|
|
|
200
283
|
|
|
201
284
|
private
|
|
202
285
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
c.eval(FETCH_SCRIPT, keys: keys) # loads it into the script cache for next time
|
|
286
|
+
# With a single (unpaused) queue there's no weighting to honor, so no
|
|
287
|
+
# need to poll: a blocking BLMOVE waits on the server and returns the
|
|
288
|
+
# moment a job arrives, for up to TIMEOUT — same as BasicFetch's BRPOP.
|
|
289
|
+
def blocking_fetch(queue_key)
|
|
290
|
+
raw_job = Cogworker.config.redis do |c|
|
|
291
|
+
c.blmove(queue_key, RedisKeys.in_progress(Cogworker.identity), 'RIGHT', 'LEFT', timeout: TIMEOUT)
|
|
210
292
|
end
|
|
293
|
+
raw_job && UnitOfWork.new(queue_key.delete_prefix(RedisKeys::QUEUE_PREFIX), raw_job)
|
|
294
|
+
rescue Redis::CommandError => e
|
|
295
|
+
raise unless e.message.match?(/unknown (redis )?command/i)
|
|
296
|
+
|
|
297
|
+
raise UnsupportedError, e.message
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def run_fetch_script(keys)
|
|
301
|
+
Cogworker.config.redis { |c| LuaScript.run(c, FETCH_SCRIPT, keys: keys) }
|
|
211
302
|
rescue Redis::CommandError => e
|
|
212
303
|
raise unless e.message.match?(/unknown (redis )?command/i)
|
|
213
304
|
|
|
@@ -219,7 +310,7 @@ module Cogworker
|
|
|
219
310
|
def idle_sleep
|
|
220
311
|
max = Cogworker.config.fetch_idle_max_interval
|
|
221
312
|
@idle_interval = @idle_interval ? [@idle_interval * 2, max].min : [EMPTY_POLL_INTERVAL, max].min
|
|
222
|
-
|
|
313
|
+
interruptible_sleep(@idle_interval * (0.75 + (rand * 0.25)))
|
|
223
314
|
end
|
|
224
315
|
end
|
|
225
316
|
end
|
data/lib/cogworker/scheduled.rb
CHANGED
|
@@ -55,6 +55,12 @@ module Cogworker
|
|
|
55
55
|
@thread&.kill
|
|
56
56
|
end
|
|
57
57
|
|
|
58
|
+
# True once the first orphan check has completed — what the periodic
|
|
59
|
+
# Ticker waits for before its first tick (see Launcher#initialize).
|
|
60
|
+
def recovered_once?
|
|
61
|
+
@recovered_once == true
|
|
62
|
+
end
|
|
63
|
+
|
|
58
64
|
private
|
|
59
65
|
|
|
60
66
|
# A failed poll is logged and retried next interval rather than ending
|
|
@@ -72,13 +78,17 @@ module Cogworker
|
|
|
72
78
|
end
|
|
73
79
|
end
|
|
74
80
|
|
|
81
|
+
# Next check scheduled only once this one has completed: a failed one
|
|
82
|
+
# (Redis down) is retried on the next poll rather than a minute later.
|
|
75
83
|
def recover_orphans_if_due
|
|
76
84
|
now = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
77
85
|
return if @next_orphan_check && now < @next_orphan_check
|
|
78
86
|
|
|
79
|
-
|
|
87
|
+
reliable = @manager.fetch_class == ReliableFetch
|
|
88
|
+
ReliableFetch.recover_orphans(scan: !@recovered_once && reliable)
|
|
89
|
+
@strays = ReliableFetch.reconcile(Cogworker.identity, @strays || []) if reliable
|
|
90
|
+
@recovered_once = true
|
|
80
91
|
@next_orphan_check = now + ORPHAN_CHECK_INTERVAL
|
|
81
|
-
ReliableFetch.recover_orphans(scan: scan)
|
|
82
92
|
end
|
|
83
93
|
|
|
84
94
|
def enqueue_due_jobs
|
|
@@ -104,8 +114,8 @@ module Cogworker
|
|
|
104
114
|
def bury(conn, set, raw)
|
|
105
115
|
error = JSON::ParserError.new("not a job (JSON object with a queue) in #{set}")
|
|
106
116
|
job = JobUtil.unparseable_job(raw, queue: nil, error: error)
|
|
107
|
-
buried =
|
|
108
|
-
|
|
117
|
+
buried = LuaScript.run(conn, BURY_SCRIPT, keys: [set, RedisKeys::DEAD, RedisKeys::STATS_FAILED],
|
|
118
|
+
argv: [raw, job['failed_at'], JSON.generate(job)])
|
|
109
119
|
return unless buried == 1
|
|
110
120
|
|
|
111
121
|
Throughput.record('failed') # counted like Processor#bury_unparseable
|
|
@@ -15,7 +15,7 @@ module Cogworker
|
|
|
15
15
|
write(job, queue, 'complete')
|
|
16
16
|
rescue Exception => e # rubocop:disable Lint/RescueException
|
|
17
17
|
write(job, queue, JobUtil.terminal_failure?(job) ? 'failed' : 'retrying',
|
|
18
|
-
error_class: e.class.name, error_message: e
|
|
18
|
+
error_class: e.class.name, error_message: JobUtil.error_message(e))
|
|
19
19
|
raise e
|
|
20
20
|
end
|
|
21
21
|
|
data/lib/cogworker/version.rb
CHANGED
|
@@ -20,6 +20,13 @@ module Cogworker
|
|
|
20
20
|
def call(env)
|
|
21
21
|
env['rack.session'] ? @app.call(env) : @with_session.call(env)
|
|
22
22
|
end
|
|
23
|
+
|
|
24
|
+
# A session middleware added via `Web.use` sits *after* this one in
|
|
25
|
+
# the stack, so it can't be detected per request the way an upstream
|
|
26
|
+
# one is — `Web.build_app` checks for one up front instead.
|
|
27
|
+
def self.session_middleware?(middleware)
|
|
28
|
+
middleware.is_a?(Class) && middleware <= Rack::Session::Abstract::Persisted
|
|
29
|
+
end
|
|
23
30
|
end
|
|
24
31
|
end
|
|
25
32
|
end
|
data/lib/cogworker/web.rb
CHANGED
|
@@ -104,9 +104,10 @@ module Cogworker
|
|
|
104
104
|
@session_secret ||= ENV.fetch('COGWORKER_SESSION_SECRET') { SecureRandom.hex(32) }
|
|
105
105
|
end
|
|
106
106
|
|
|
107
|
-
# `false` drops the built-in session cookie entirely
|
|
108
|
-
# skipped automatically whenever the host app provides a session
|
|
109
|
-
#
|
|
107
|
+
# `false` drops the built-in session cookie entirely. It's already
|
|
108
|
+
# skipped automatically whenever the host app provides a session
|
|
109
|
+
# (OptionalSession) or one is added via `Web.use` (a
|
|
110
|
+
# `Rack::Session::*` class — see `build_app`). Default true.
|
|
110
111
|
def builtin_session
|
|
111
112
|
@builtin_session.nil? || @builtin_session
|
|
112
113
|
end
|
|
@@ -258,7 +259,7 @@ module Cogworker
|
|
|
258
259
|
|
|
259
260
|
def build_app
|
|
260
261
|
builder = Rack::Builder.new
|
|
261
|
-
if builtin_session
|
|
262
|
+
if builtin_session && middlewares.none? { |mw, _| Cogworker::Web::OptionalSession.session_middleware?(mw) }
|
|
262
263
|
builder.use(Cogworker::Web::OptionalSession, secret: session_secret, key: 'cogworker.session')
|
|
263
264
|
end
|
|
264
265
|
# No `cache_control:` (no `immutable`/long `max-age`): these files
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: cogworker
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.4.
|
|
4
|
+
version: 0.4.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- vdonec
|
|
@@ -222,6 +222,7 @@ files:
|
|
|
222
222
|
- lib/cogworker/job_util.rb
|
|
223
223
|
- lib/cogworker/launcher.rb
|
|
224
224
|
- lib/cogworker/logging.rb
|
|
225
|
+
- lib/cogworker/lua_script.rb
|
|
225
226
|
- lib/cogworker/manager.rb
|
|
226
227
|
- lib/cogworker/middleware/chain.rb
|
|
227
228
|
- lib/cogworker/owned_key.rb
|